@usefillo/react 0.10.0 → 0.12.0

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/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  useEffect as useEffect8,
7
7
  useId as useId2,
8
8
  useMemo as useMemo5,
9
- useRef as useRef7,
9
+ useRef as useRef8,
10
10
  useState as useState6,
11
11
  useSyncExternalStore as useSyncExternalStore2
12
12
  } from "react";
@@ -15,15 +15,17 @@ import {
15
15
  createClient,
16
16
  formSchemasEqual,
17
17
  isBuildTimeDevEnv as isBuildTimeDevEnv2,
18
+ isField as isField2,
18
19
  isFilloError as isFilloError2,
19
20
  isLikelyDevEnv,
20
21
  needsExplicitSubmit,
21
22
  normalizeFormSchema,
22
23
  normalizeFormTheme,
23
24
  reachableFields,
24
- reachablePageSequence
25
+ reachablePageSequence,
26
+ resolveThemeAppearance
25
27
  } from "@usefillo/core";
26
- import { resolveStrings, slotClass as slotClass3 } from "@usefillo/core";
28
+ import { REQUIRED_FIELD_MESSAGE as REQUIRED_FIELD_MESSAGE2, resolveStrings, slotClass as slotClass3 } from "@usefillo/core";
27
29
 
28
30
  // src/appearance.tsx
29
31
  import { createContext, useContext } from "react";
@@ -155,6 +157,12 @@ var FilloChromeContext = createContext2(null);
155
157
  function useFilloChrome() {
156
158
  return useContext2(FilloChromeContext);
157
159
  }
160
+ var noopAnnounce = () => {
161
+ };
162
+ var FilloAnnounceContext = createContext2(noopAnnounce);
163
+ function useFilloAnnounce() {
164
+ return useContext2(FilloAnnounceContext);
165
+ }
158
166
  function useFilloInstanceId() {
159
167
  const instanceId = useContext2(FilloInstanceIdContext);
160
168
  const fallbackId = useId().replace(/:/g, "");
@@ -328,6 +336,7 @@ function useCodeFormSync(form, client, onError, options) {
328
336
  accepting: void 0,
329
337
  acceptingReason: void 0,
330
338
  uploadsAvailable: void 0,
339
+ uploadFileSizeLimitMb: void 0,
331
340
  resolvedSchema: void 0,
332
341
  resolvedTheme: void 0,
333
342
  warning: void 0,
@@ -390,6 +399,7 @@ function useCodeFormSync(form, client, onError, options) {
390
399
  accepting: r.accepting,
391
400
  acceptingReason: r.acceptingReason,
392
401
  uploadsAvailable: r.uploadsAvailable,
402
+ uploadFileSizeLimitMb: r.uploadFileSizeLimitMb,
393
403
  resolvedSchema: r.resolvedSchema,
394
404
  resolvedTheme: r.resolvedTheme,
395
405
  warning: r.warning,
@@ -451,6 +461,7 @@ function useCodeFormSync(form, client, onError, options) {
451
461
  accepting: synced.accepting,
452
462
  acceptingReason: synced.acceptingReason,
453
463
  uploadsAvailable: synced.uploadsAvailable,
464
+ uploadFileSizeLimitMb: synced.uploadFileSizeLimitMb,
454
465
  resolvedSchema: synced.resolvedSchema,
455
466
  resolvedTheme: synced.resolvedTheme,
456
467
  warning: synced.warning,
@@ -467,12 +478,26 @@ function useCodeFormSync(form, client, onError, options) {
467
478
  }
468
479
 
469
480
  // src/fields.tsx
470
- import { isField, pipeBlock, REQUIRED_FIELD_MESSAGE, shouldAutoSubmit, slotClass as slotClass2, visibleFields } from "@usefillo/core";
471
- import { memo, useEffect as useEffect6, useMemo as useMemo4, useState as useState5 } from "react";
481
+ import {
482
+ formatAnswer,
483
+ formatGroupedNumber,
484
+ isField,
485
+ isValidPartialNumberText,
486
+ localeForNotation,
487
+ parseGroupedNumber,
488
+ pipeBlock,
489
+ radioGroupStep,
490
+ REQUIRED_FIELD_MESSAGE,
491
+ shouldAutoSubmit,
492
+ slotClass as slotClass2,
493
+ visibleFields
494
+ } from "@usefillo/core";
495
+ import { memo, useEffect as useEffect6, useLayoutEffect as useLayoutEffect2, useMemo as useMemo4, useRef as useRef6, useState as useState5 } from "react";
472
496
 
473
497
  // src/phone.tsx
474
498
  import {
475
499
  PHONE_COUNTRIES,
500
+ PHONE_PICKER_COUNTRIES,
476
501
  countryByIso,
477
502
  countryByTimeZone,
478
503
  digitsOnly,
@@ -524,8 +549,9 @@ function PhoneField({ field, value, error, setValue, ids: providedIds }) {
524
549
  const guess = countryByTimeZone(browserTimeZone()) ?? countryByIso(localeCountry());
525
550
  if (guess) setCountry(guess);
526
551
  }, []);
552
+ const [pendingRaw, setPendingRaw] = useState2(null);
527
553
  const national = nationalFor(stored, country);
528
- const display = formatNational(country, national);
554
+ const display = pendingRaw ?? formatNational(country, national);
529
555
  const inputRef = useRef3(null);
530
556
  const caretDigits = useRef3(null);
531
557
  useLayoutEffect(() => {
@@ -552,15 +578,23 @@ function PhoneField({ field, value, error, setValue, ids: providedIds }) {
552
578
  const caret = el.selectionStart ?? raw.length;
553
579
  caretDigits.current = digitsOnly(raw.slice(0, caret)).length;
554
580
  if (raw.trimStart().startsWith("+")) {
555
- const p = parsePhone(raw);
581
+ const p = parsePhone(raw, country, { previousDigits: national });
582
+ if (p.pending) {
583
+ setPendingRaw(p.raw ?? raw);
584
+ setValue(p.raw ?? raw);
585
+ return;
586
+ }
587
+ setPendingRaw(null);
556
588
  if (p.country) setCountry(p.country);
557
589
  commit(p.country ?? country, p.country ? p.national : digitsOnly(raw));
558
590
  return;
559
591
  }
592
+ setPendingRaw(null);
560
593
  commit(country, digitsOnly(raw));
561
594
  }
562
595
  function pickCountry(c) {
563
596
  touched.current = true;
597
+ setPendingRaw(null);
564
598
  setCountry(c);
565
599
  commit(c, national);
566
600
  inputRef.current?.focus();
@@ -593,7 +627,7 @@ function PhoneField({ field, value, error, setValue, ids: providedIds }) {
593
627
  }
594
628
  )
595
629
  ] }),
596
- error && /* @__PURE__ */ jsx2("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, role: "alert", children: error })
630
+ error && /* @__PURE__ */ jsx2("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, children: error })
597
631
  ] });
598
632
  }
599
633
  function CountrySelect({
@@ -601,6 +635,8 @@ function CountrySelect({
601
635
  onPick,
602
636
  labelledBy
603
637
  }) {
638
+ const strings = useStrings();
639
+ const announce = useFilloAnnounce();
604
640
  const [open, setOpen] = useState2(false);
605
641
  const [query, setQuery] = useState2("");
606
642
  const [active, setActive] = useState2(0);
@@ -609,19 +645,22 @@ function CountrySelect({
609
645
  const triggerRef = useRef3(null);
610
646
  const popoverRef = useRef3(null);
611
647
  const searchRef = useRef3(null);
648
+ const seedRef = useRef3(null);
612
649
  const listId = `${labelledBy}-countries`;
613
650
  const matches = useMemo3(() => {
614
651
  const q = query.trim().toLowerCase();
615
- if (!q) return PHONE_COUNTRIES;
652
+ if (!q) return PHONE_PICKER_COUNTRIES;
616
653
  const qDigits = q.replace(/[^\d]/g, "");
617
- return PHONE_COUNTRIES.filter(
654
+ return PHONE_PICKER_COUNTRIES.filter(
618
655
  (c) => c.name.toLowerCase().includes(q) || c.iso2.toLowerCase() === q || qDigits.length > 0 && c.dialCode.startsWith(qDigits)
619
656
  );
620
657
  }, [query]);
621
658
  useEffect3(() => {
622
659
  if (!open) return;
623
- setQuery("");
624
- setActive(Math.max(0, PHONE_COUNTRIES.indexOf(country)));
660
+ const seed = seedRef.current ?? "";
661
+ seedRef.current = null;
662
+ setQuery(seed);
663
+ setActive(seed ? 0 : Math.max(0, PHONE_PICKER_COUNTRIES.indexOf(country)));
625
664
  const id = requestAnimationFrame(() => searchRef.current?.focus());
626
665
  return () => cancelAnimationFrame(id);
627
666
  }, [open, country]);
@@ -648,8 +687,15 @@ function CountrySelect({
648
687
  if (!open) return;
649
688
  document.getElementById(`${listId}-opt-${active}`)?.scrollIntoView({ block: "nearest" });
650
689
  }, [active, open, listId]);
690
+ const resultsAnnouncement = strings.phoneResultsCount(matches.length);
691
+ useEffect3(() => {
692
+ if (!open || !query.trim()) return;
693
+ const id = setTimeout(() => announce(resultsAnnouncement), 300);
694
+ return () => clearTimeout(id);
695
+ }, [open, query, resultsAnnouncement, announce]);
651
696
  function choose(c) {
652
697
  if (!c) return;
698
+ announce(strings.phoneCountrySelected(c.name));
653
699
  onPick(c);
654
700
  setOpen(false);
655
701
  }
@@ -669,7 +715,30 @@ function CountrySelect({
669
715
  triggerRef.current?.focus();
670
716
  }
671
717
  }
672
- return /* @__PURE__ */ jsxs2("div", { className: "fillo-phone-country", ref: rootRef, children: [
718
+ function onTriggerKeyDown(e) {
719
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
720
+ e.preventDefault();
721
+ setOpen(true);
722
+ return;
723
+ }
724
+ if (e.key.length === 1 && e.key !== " " && !e.ctrlKey && !e.altKey && !e.metaKey) {
725
+ e.preventDefault();
726
+ seedRef.current = e.key;
727
+ setOpen(true);
728
+ }
729
+ }
730
+ function onCompositeBlur(e) {
731
+ if (!open) return;
732
+ const next = e.relatedTarget;
733
+ if (next) {
734
+ if (!rootRef.current?.contains(next)) setOpen(false);
735
+ return;
736
+ }
737
+ queueMicrotask(() => {
738
+ if (!rootRef.current?.contains(document.activeElement)) setOpen(false);
739
+ });
740
+ }
741
+ return /* @__PURE__ */ jsxs2("div", { className: "fillo-phone-country", ref: rootRef, onBlur: onCompositeBlur, children: [
673
742
  /* @__PURE__ */ jsxs2(
674
743
  "button",
675
744
  {
@@ -681,6 +750,7 @@ function CountrySelect({
681
750
  "aria-controls": open ? listId : void 0,
682
751
  "aria-label": `Country: ${country.name} (+${country.dialCode})`,
683
752
  onClick: () => setOpen((o) => !o),
753
+ onKeyDown: onTriggerKeyDown,
684
754
  children: [
685
755
  /* @__PURE__ */ jsx2("span", { className: "fillo-phone-flag-emoji", "aria-hidden": "true", children: flagEmoji(country.iso2) }),
686
756
  /* @__PURE__ */ jsxs2("span", { className: "fillo-phone-dial", children: [
@@ -737,7 +807,7 @@ function CountrySelect({
737
807
  },
738
808
  c.iso2
739
809
  )),
740
- matches.length === 0 && /* @__PURE__ */ jsx2("li", { className: "fillo-phone-empty", children: "No matches" })
810
+ matches.length === 0 && /* @__PURE__ */ jsx2("li", { className: "fillo-phone-empty", role: "option", "aria-disabled": "true", children: "No matches" })
741
811
  ] })
742
812
  ] })
743
813
  ] });
@@ -752,6 +822,7 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
752
822
  const strings = useStrings();
753
823
  const typedInputId = `${ids.inputId}-typed`;
754
824
  const canvasRef = useRef4(null);
825
+ const typedInputRef = useRef4(null);
755
826
  const drawing = useRef4(false);
756
827
  const [hasInk, setHasInk] = useState3(Boolean(value));
757
828
  const [typedName, setTypedName] = useState3("");
@@ -848,6 +919,7 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
848
919
  setHasInk(false);
849
920
  setTypedName("");
850
921
  setValue(null);
922
+ typedInputRef.current?.focus();
851
923
  }
852
924
  const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ") || void 0;
853
925
  return /* @__PURE__ */ jsxs3("div", { ...slots.wrapperProps(`fillo-field fillo-field--signature${error ? " fillo-field--error" : ""}`), children: [
@@ -869,7 +941,8 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
869
941
  {
870
942
  ref: canvasRef,
871
943
  className: "fillo-signature-canvas",
872
- "aria-hidden": "true",
944
+ role: "img",
945
+ "aria-label": hasInk ? strings.signatureSigned : strings.signatureEmpty,
873
946
  onPointerDown: start,
874
947
  onPointerMove: move,
875
948
  onPointerUp: end,
@@ -886,6 +959,7 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
886
959
  "input",
887
960
  {
888
961
  id: typedInputId,
962
+ ref: typedInputRef,
889
963
  type: "text",
890
964
  className: "fillo-input fillo-signature-type-input",
891
965
  autoComplete: "name",
@@ -904,7 +978,7 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
904
978
  ]
905
979
  }
906
980
  ),
907
- error && /* @__PURE__ */ jsx3("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, role: "alert", children: error })
981
+ error && /* @__PURE__ */ jsx3("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, children: error })
908
982
  ] });
909
983
  }
910
984
 
@@ -926,7 +1000,22 @@ function formatBytes(bytes) {
926
1000
  if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
927
1001
  return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
928
1002
  }
929
- function FileUploadField({ field, value, error, setValue, api, ids: providedIds }) {
1003
+ function uploadFailureMessage(error, strings) {
1004
+ if (error instanceof Error && error.name === "FilloError") {
1005
+ const status = error.status;
1006
+ if (status !== void 0 && status >= 500) return strings.uploadUnavailable;
1007
+ return error.message || strings.uploadFailed;
1008
+ }
1009
+ return strings.uploadFailed;
1010
+ }
1011
+ function FileUploadField({
1012
+ field,
1013
+ value,
1014
+ error,
1015
+ setValue,
1016
+ api,
1017
+ ids: providedIds
1018
+ }) {
930
1019
  const ids = useFilloFieldIds(field.id, providedIds);
931
1020
  const slots = useFieldSlots(field, error);
932
1021
  const strings = useStrings();
@@ -952,11 +1041,13 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
952
1041
  const filesRef = useRef5(files);
953
1042
  filesRef.current = files;
954
1043
  const maxFiles = schema.maxFiles ?? 1;
955
- const maxMb = schema.maxFileSizeMb ?? 500;
1044
+ const configuredMaxMb = schema.maxFileSizeMb ?? 500;
1045
+ const maxMb = chrome?.uploadFileSizeLimitMb ? Math.min(configuredMaxMb, chrome.uploadFileSizeLimitMb) : configuredMaxMb;
956
1046
  const maxBytes = maxMb * 1024 * 1024;
1047
+ const renderOnly = chrome?.renderOnly === true;
957
1048
  const storageBlocked = chrome?.uploadsAvailable === false;
958
1049
  const storageUrl = storageBlocked && chrome?.devChrome ? safeHttpUrl(chrome.warningUrl) : null;
959
- const canUpload = Boolean(api.client && api.formId) && !storageBlocked;
1050
+ const canUpload = !renderOnly && Boolean(api.client && api.formId) && !storageBlocked;
960
1051
  const activeInFlight = inFlight.filter((f) => !f.error).length;
961
1052
  const remaining = maxFiles - files.length - activeInFlight;
962
1053
  async function startUpload(file) {
@@ -965,7 +1056,15 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
965
1056
  if (file.size > maxBytes) {
966
1057
  setInFlight((prev) => [
967
1058
  ...prev,
968
- { key, name: file.name, size: file.size, fraction: 0, file, tooLarge: true, error: strings.fileTooLarge(maxMb) }
1059
+ {
1060
+ key,
1061
+ name: file.name,
1062
+ size: file.size,
1063
+ fraction: 0,
1064
+ file,
1065
+ tooLarge: true,
1066
+ error: strings.fileTooLarge(maxMb)
1067
+ }
969
1068
  ]);
970
1069
  return;
971
1070
  }
@@ -991,10 +1090,8 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
991
1090
  setValue(next);
992
1091
  } catch (err) {
993
1092
  if (mountedRef.current) {
994
- const message = err instanceof Error && err.name === "FilloError" && err.message ? err.message : strings.uploadFailed;
995
- setInFlight(
996
- (prev) => prev.map((f) => f.key === key ? { ...f, error: message } : f)
997
- );
1093
+ const message = uploadFailureMessage(err, strings);
1094
+ setInFlight((prev) => prev.map((f) => f.key === key ? { ...f, error: message } : f));
998
1095
  }
999
1096
  } finally {
1000
1097
  controllers.current.delete(key);
@@ -1018,171 +1115,190 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
1018
1115
  setValue(next);
1019
1116
  }
1020
1117
  const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ") || void 0;
1021
- return /* @__PURE__ */ jsxs4("div", { ...slots.wrapperProps(`fillo-field fillo-field--file_upload${error ? " fillo-field--error" : ""}`), children: [
1022
- /* @__PURE__ */ jsxs4("label", { className: slots.label, "data-fillo": "label", id: ids.labelId, htmlFor: ids.inputId, children: [
1023
- field.label,
1024
- !field.required && /* @__PURE__ */ jsx4("span", { className: "fillo-optional", children: strings.optional })
1025
- ] }),
1026
- field.description && /* @__PURE__ */ jsx4("p", { className: slots.description, "data-fillo": "fieldDescription", id: ids.descriptionId, children: field.description }),
1027
- remaining > 0 && /* @__PURE__ */ jsxs4(
1028
- "div",
1029
- {
1030
- "data-fillo": "control",
1031
- "data-drag-over": dragOver ? "" : void 0,
1032
- className: slots.control(`fillo-dropzone${dragOver ? " fillo-dropzone--over" : ""}${canUpload ? "" : " fillo-dropzone--disabled"}`),
1033
- onDragOver: (e) => {
1034
- e.preventDefault();
1035
- setDragOver(true);
1036
- },
1037
- onDragLeave: () => setDragOver(false),
1038
- onDrop: (e) => {
1039
- e.preventDefault();
1040
- setDragOver(false);
1041
- if (canUpload) handleFiles(e.dataTransfer.files);
1042
- },
1043
- onClick: () => canUpload && inputRef.current?.click(),
1044
- role: "button",
1045
- tabIndex: 0,
1046
- "aria-disabled": !canUpload || void 0,
1047
- "aria-labelledby": ids.labelId,
1048
- "aria-required": field.required ? true : void 0,
1049
- "aria-invalid": error ? true : void 0,
1050
- "aria-describedby": describedBy,
1051
- onKeyDown: (e) => {
1052
- if (e.key === "Enter" || e.key === " ") {
1053
- e.preventDefault();
1054
- if (canUpload) inputRef.current?.click();
1118
+ return /* @__PURE__ */ jsxs4(
1119
+ "div",
1120
+ {
1121
+ ...slots.wrapperProps(
1122
+ `fillo-field fillo-field--file_upload${error ? " fillo-field--error" : ""}`
1123
+ ),
1124
+ children: [
1125
+ /* @__PURE__ */ jsxs4("label", { className: slots.label, "data-fillo": "label", id: ids.labelId, htmlFor: ids.inputId, children: [
1126
+ field.label,
1127
+ !field.required && /* @__PURE__ */ jsx4("span", { className: "fillo-optional", children: strings.optional })
1128
+ ] }),
1129
+ field.description && /* @__PURE__ */ jsx4("p", { className: slots.description, "data-fillo": "fieldDescription", id: ids.descriptionId, children: field.description }),
1130
+ remaining > 0 && /* @__PURE__ */ jsxs4(
1131
+ "div",
1132
+ {
1133
+ "data-fillo": "control",
1134
+ "data-drag-over": dragOver ? "" : void 0,
1135
+ className: slots.control(
1136
+ `fillo-dropzone${dragOver ? " fillo-dropzone--over" : ""}${canUpload ? "" : " fillo-dropzone--disabled"}`
1137
+ ),
1138
+ onDragOver: (e) => {
1139
+ e.preventDefault();
1140
+ setDragOver(true);
1141
+ },
1142
+ onDragLeave: () => setDragOver(false),
1143
+ onDrop: (e) => {
1144
+ e.preventDefault();
1145
+ setDragOver(false);
1146
+ if (canUpload) handleFiles(e.dataTransfer.files);
1147
+ },
1148
+ onClick: () => canUpload && inputRef.current?.click(),
1149
+ role: "button",
1150
+ tabIndex: 0,
1151
+ "aria-disabled": !canUpload || void 0,
1152
+ "aria-labelledby": ids.labelId,
1153
+ "aria-invalid": error ? true : void 0,
1154
+ "aria-describedby": describedBy,
1155
+ onKeyDown: (e) => {
1156
+ if (e.key === "Enter" || e.key === " ") {
1157
+ e.preventDefault();
1158
+ if (canUpload) inputRef.current?.click();
1159
+ }
1160
+ },
1161
+ children: [
1162
+ /* @__PURE__ */ jsx4(
1163
+ "input",
1164
+ {
1165
+ ref: inputRef,
1166
+ id: ids.inputId,
1167
+ type: "file",
1168
+ hidden: true,
1169
+ disabled: !canUpload,
1170
+ multiple: maxFiles > 1,
1171
+ accept: schema.accept?.join(","),
1172
+ onChange: (e) => canUpload && handleFiles(e.target.files)
1173
+ }
1174
+ ),
1175
+ canUpload ? /* @__PURE__ */ jsxs4(Fragment2, { children: [
1176
+ /* @__PURE__ */ jsx4("span", { className: "fillo-dropzone-title", children: strings.dropzoneTitle(maxFiles > 1) }),
1177
+ /* @__PURE__ */ jsx4("span", { className: "fillo-dropzone-hint", children: strings.dropzoneHint(maxMb) })
1178
+ ] }) : storageBlocked && chrome?.devChrome ? /* @__PURE__ */ jsxs4("span", { className: "fillo-dropzone-hint", children: [
1179
+ "Connect file storage to enable uploads",
1180
+ storageUrl && /* @__PURE__ */ jsxs4(Fragment2, { children: [
1181
+ " \u2014 ",
1182
+ /* @__PURE__ */ jsx4("a", { href: storageUrl, target: "_blank", rel: "noopener noreferrer", children: storageUrl })
1183
+ ] })
1184
+ ] }) : /* @__PURE__ */ jsx4("span", { className: "fillo-dropzone-hint", children: renderOnly ? strings.uploadsRenderOnly : storageBlocked ? strings.uploadsUnavailable : strings.uploadsDisabled })
1185
+ ]
1055
1186
  }
1056
- },
1057
- children: [
1058
- /* @__PURE__ */ jsx4(
1059
- "input",
1060
- {
1061
- ref: inputRef,
1062
- id: ids.inputId,
1063
- type: "file",
1064
- hidden: true,
1065
- disabled: !canUpload,
1066
- multiple: maxFiles > 1,
1067
- accept: schema.accept?.join(","),
1068
- onChange: (e) => canUpload && handleFiles(e.target.files)
1069
- }
1070
- ),
1071
- canUpload ? /* @__PURE__ */ jsxs4(Fragment2, { children: [
1072
- /* @__PURE__ */ jsx4("span", { className: "fillo-dropzone-title", children: strings.dropzoneTitle(maxFiles > 1) }),
1073
- /* @__PURE__ */ jsx4("span", { className: "fillo-dropzone-hint", children: strings.dropzoneHint(maxMb) })
1074
- ] }) : storageBlocked && chrome?.devChrome ? /* @__PURE__ */ jsxs4("span", { className: "fillo-dropzone-hint", children: [
1075
- "Connect file storage to enable uploads",
1076
- storageUrl && /* @__PURE__ */ jsxs4(Fragment2, { children: [
1077
- " \u2014 ",
1078
- /* @__PURE__ */ jsx4("a", { href: storageUrl, target: "_blank", rel: "noopener noreferrer", children: storageUrl })
1079
- ] })
1080
- ] }) : /* @__PURE__ */ jsx4("span", { className: "fillo-dropzone-hint", children: storageBlocked ? strings.uploadsUnavailable : strings.uploadsDisabled })
1081
- ]
1082
- }
1083
- ),
1084
- /* @__PURE__ */ jsx4(
1085
- "div",
1086
- {
1087
- role: "status",
1088
- "aria-live": "polite",
1089
- className: "fillo-sr-only",
1090
- style: { position: "absolute", left: "-9999px", width: 1, height: 1, overflow: "hidden" },
1091
- children: uploadStatus(inFlight, files.length, strings)
1092
- }
1093
- ),
1094
- (inFlight.length > 0 || files.length > 0) && /* @__PURE__ */ jsxs4("ul", { className: "fillo-files", children: [
1095
- files.map((f) => /* @__PURE__ */ jsxs4("li", { className: "fillo-file fillo-file--done", children: [
1096
- /* @__PURE__ */ jsx4("span", { className: "fillo-file-name", children: f.name }),
1097
- /* @__PURE__ */ jsx4("span", { className: "fillo-file-meta", children: formatBytes(f.size) }),
1187
+ ),
1098
1188
  /* @__PURE__ */ jsx4(
1099
- "button",
1189
+ "div",
1100
1190
  {
1101
- type: "button",
1102
- className: "fillo-file-remove",
1103
- "aria-label": `Remove ${f.name}`,
1104
- onClick: () => removeFile(f.fileId),
1105
- children: "\xD7"
1191
+ role: "status",
1192
+ "aria-live": "polite",
1193
+ className: "fillo-sr-only",
1194
+ style: { position: "absolute", left: "-9999px", width: 1, height: 1, overflow: "hidden" },
1195
+ children: uploadStatus(inFlight, files.length, strings)
1106
1196
  }
1107
- )
1108
- ] }, f.fileId)),
1109
- inFlight.map((f) => /* @__PURE__ */ jsxs4(
1110
- "li",
1111
- {
1112
- className: `fillo-file${f.error ? " fillo-file--failed" : ""}`,
1113
- ...f.error ? {} : {
1114
- role: "progressbar",
1115
- "aria-label": `Uploading ${f.name}`,
1116
- "aria-valuemin": 0,
1117
- "aria-valuemax": 100,
1118
- "aria-valuenow": Math.round(f.fraction * 100)
1119
- },
1120
- children: [
1197
+ ),
1198
+ (inFlight.length > 0 || files.length > 0) && /* @__PURE__ */ jsxs4("ul", { className: "fillo-files", children: [
1199
+ files.map((f) => /* @__PURE__ */ jsxs4("li", { className: "fillo-file fillo-file--done", children: [
1121
1200
  /* @__PURE__ */ jsx4("span", { className: "fillo-file-name", children: f.name }),
1122
- f.error ? /* @__PURE__ */ jsxs4(Fragment2, { children: [
1123
- /* @__PURE__ */ jsx4("span", { className: "fillo-file-meta", children: f.error }),
1124
- f.file && !f.tooLarge && remaining > 0 && /* @__PURE__ */ jsx4(
1125
- "button",
1126
- {
1127
- type: "button",
1128
- className: "fillo-file-retry",
1129
- "aria-label": `${strings.uploadRetry} ${f.name}`,
1130
- onClick: () => {
1131
- const retryFile = f.file;
1132
- if (!retryFile) return;
1133
- setInFlight((prev) => prev.filter((x) => x.key !== f.key));
1134
- void startUpload(retryFile);
1135
- },
1136
- children: strings.uploadRetry
1137
- }
1138
- ),
1139
- /* @__PURE__ */ jsx4(
1140
- "button",
1141
- {
1142
- type: "button",
1143
- className: "fillo-file-remove",
1144
- "aria-label": "Dismiss",
1145
- onClick: () => setInFlight((prev) => prev.filter((x) => x.key !== f.key)),
1146
- children: "\xD7"
1147
- }
1148
- )
1149
- ] }) : /* @__PURE__ */ jsxs4(Fragment2, { children: [
1150
- /* @__PURE__ */ jsxs4("span", { className: "fillo-file-meta", children: [
1151
- Math.round(f.fraction * 100),
1152
- "% of ",
1153
- formatBytes(f.size)
1154
- ] }),
1155
- /* @__PURE__ */ jsx4("span", { className: "fillo-progress", "aria-hidden": "true", children: /* @__PURE__ */ jsx4("span", { className: "fillo-progress-bar", style: { width: `${f.fraction * 100}%` } }) }),
1156
- /* @__PURE__ */ jsx4(
1157
- "button",
1158
- {
1159
- type: "button",
1160
- className: "fillo-file-remove",
1161
- "aria-label": `Cancel ${f.name}`,
1162
- onClick: () => cancelUpload(f.key),
1163
- children: "\xD7"
1164
- }
1165
- )
1166
- ] })
1167
- ]
1168
- },
1169
- f.key
1170
- ))
1171
- ] }),
1172
- error && /* @__PURE__ */ jsx4("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, role: "alert", children: error })
1173
- ] });
1201
+ /* @__PURE__ */ jsx4("span", { className: "fillo-file-meta", children: formatBytes(f.size) }),
1202
+ /* @__PURE__ */ jsx4("span", { className: "fillo-sr-only", children: "Uploaded" }),
1203
+ /* @__PURE__ */ jsx4(
1204
+ "button",
1205
+ {
1206
+ type: "button",
1207
+ className: "fillo-file-remove",
1208
+ "aria-label": `Remove ${f.name}`,
1209
+ onClick: () => removeFile(f.fileId),
1210
+ children: "\xD7"
1211
+ }
1212
+ )
1213
+ ] }, f.fileId)),
1214
+ inFlight.map((f) => /* @__PURE__ */ jsxs4(
1215
+ "li",
1216
+ {
1217
+ className: `fillo-file${f.error ? " fillo-file--failed" : ""}`,
1218
+ ...f.error ? {} : {
1219
+ role: "progressbar",
1220
+ "aria-label": `Uploading ${f.name}`,
1221
+ "aria-valuemin": 0,
1222
+ "aria-valuemax": 100,
1223
+ "aria-valuenow": Math.round(f.fraction * 100)
1224
+ },
1225
+ children: [
1226
+ /* @__PURE__ */ jsx4("span", { className: "fillo-file-name", children: f.name }),
1227
+ f.error ? /* @__PURE__ */ jsxs4(Fragment2, { children: [
1228
+ /* @__PURE__ */ jsxs4("span", { className: "fillo-file-actions", children: [
1229
+ f.file && !f.tooLarge && remaining > 0 && /* @__PURE__ */ jsx4(
1230
+ "button",
1231
+ {
1232
+ type: "button",
1233
+ className: "fillo-file-retry",
1234
+ "aria-label": `${strings.uploadRetry} ${f.name}`,
1235
+ onClick: () => {
1236
+ const retryFile = f.file;
1237
+ if (!retryFile) return;
1238
+ setInFlight((prev) => prev.filter((x) => x.key !== f.key));
1239
+ void startUpload(retryFile);
1240
+ },
1241
+ children: strings.uploadRetry
1242
+ }
1243
+ ),
1244
+ /* @__PURE__ */ jsx4(
1245
+ "button",
1246
+ {
1247
+ type: "button",
1248
+ className: "fillo-file-remove",
1249
+ "aria-label": "Dismiss",
1250
+ onClick: () => setInFlight((prev) => prev.filter((x) => x.key !== f.key)),
1251
+ children: "\xD7"
1252
+ }
1253
+ )
1254
+ ] }),
1255
+ /* @__PURE__ */ jsx4("span", { className: "fillo-file-error", role: "alert", children: f.error })
1256
+ ] }) : /* @__PURE__ */ jsxs4(Fragment2, { children: [
1257
+ /* @__PURE__ */ jsxs4("span", { className: "fillo-file-meta", children: [
1258
+ Math.round(f.fraction * 100),
1259
+ "% of ",
1260
+ formatBytes(f.size)
1261
+ ] }),
1262
+ /* @__PURE__ */ jsx4(
1263
+ "button",
1264
+ {
1265
+ type: "button",
1266
+ className: "fillo-file-remove",
1267
+ "aria-label": `Cancel ${f.name}`,
1268
+ onClick: () => cancelUpload(f.key),
1269
+ children: "\xD7"
1270
+ }
1271
+ ),
1272
+ /* @__PURE__ */ jsx4("span", { className: "fillo-progress", "aria-hidden": "true", children: /* @__PURE__ */ jsx4(
1273
+ "span",
1274
+ {
1275
+ className: "fillo-progress-bar",
1276
+ style: { width: `${f.fraction * 100}%` }
1277
+ }
1278
+ ) })
1279
+ ] })
1280
+ ]
1281
+ },
1282
+ f.key
1283
+ ))
1284
+ ] }),
1285
+ error && /* @__PURE__ */ jsx4("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, children: error })
1286
+ ]
1287
+ }
1288
+ );
1174
1289
  }
1175
1290
 
1176
1291
  // src/fields.tsx
1177
1292
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1178
- function fieldAria(field, error, ids) {
1293
+ function fieldAria(field, error, ids, opts = {}) {
1179
1294
  const describedBy = [
1180
1295
  field.description ? ids.descriptionId : null,
1181
1296
  error ? ids.errorId : null
1182
1297
  ].filter(Boolean);
1298
+ const requiredSupported = opts.required ?? true;
1183
1299
  return {
1184
1300
  "aria-invalid": error ? true : void 0,
1185
- "aria-required": field.required ? true : void 0,
1301
+ "aria-required": requiredSupported && field.required ? true : void 0,
1186
1302
  "aria-describedby": describedBy.length ? describedBy.join(" ") : void 0
1187
1303
  };
1188
1304
  }
@@ -1198,9 +1314,6 @@ function useControl(base, field, error) {
1198
1314
  "data-fillo": "control"
1199
1315
  };
1200
1316
  }
1201
- function isNativeControlTarget(target) {
1202
- return target instanceof HTMLElement && Boolean(target.closest("input, textarea, select, button"));
1203
- }
1204
1317
  function FieldShell({
1205
1318
  field,
1206
1319
  error,
@@ -1237,7 +1350,7 @@ function FieldShell({
1237
1350
  ] }),
1238
1351
  field.description && /* @__PURE__ */ jsx5("p", { className: descriptionCls, "data-fillo": "fieldDescription", id: ids.descriptionId, children: field.description }),
1239
1352
  children,
1240
- error && /* @__PURE__ */ jsx5("p", { className: errorCls, "data-fillo": "error", id: ids.errorId, role: "alert", children: error })
1353
+ error && /* @__PURE__ */ jsx5("p", { className: errorCls, "data-fillo": "error", id: ids.errorId, children: error })
1241
1354
  ]
1242
1355
  }
1243
1356
  );
@@ -1245,6 +1358,7 @@ function FieldShell({
1245
1358
  function TextInput({ field, value, error, setValue, ids: providedIds }) {
1246
1359
  const ids = useFilloFieldIds(field.id, providedIds);
1247
1360
  const type = field.kind === "email" ? "email" : field.kind === "url" ? "url" : field.kind === "number" ? "number" : field.kind === "date" ? "date" : "text";
1361
+ const autoComplete = field.kind === "email" ? "email" : field.kind === "url" ? "url" : void 0;
1248
1362
  const control = useControl("fillo-input", field, error);
1249
1363
  return /* @__PURE__ */ jsx5(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx5(
1250
1364
  "input",
@@ -1252,6 +1366,7 @@ function TextInput({ field, value, error, setValue, ids: providedIds }) {
1252
1366
  id: ids.inputId,
1253
1367
  ...control,
1254
1368
  type,
1369
+ autoComplete,
1255
1370
  value: typeof value === "string" || typeof value === "number" ? String(value) : "",
1256
1371
  placeholder: field.placeholder,
1257
1372
  maxLength: "maxLength" in field ? field.maxLength : void 0,
@@ -1262,6 +1377,72 @@ function TextInput({ field, value, error, setValue, ids: providedIds }) {
1262
1377
  }
1263
1378
  ) });
1264
1379
  }
1380
+ function NumberInput({ field, value, error, setValue, api, ids: providedIds }) {
1381
+ const ids = useFilloFieldIds(field.id, providedIds);
1382
+ const control = useControl("fillo-input", field, error);
1383
+ const [draft, setDraft] = useState5(null);
1384
+ const inputRef = useRef6(null);
1385
+ const pendingCaret = useRef6(null);
1386
+ const [, setRevertTick] = useState5(0);
1387
+ useLayoutEffect2(() => {
1388
+ if (pendingCaret.current === null) return;
1389
+ const pos = pendingCaret.current;
1390
+ pendingCaret.current = null;
1391
+ inputRef.current?.setSelectionRange(pos, pos);
1392
+ });
1393
+ const num = field;
1394
+ const grouped = num.notation !== void 0;
1395
+ const locale = localeForNotation(num.notation);
1396
+ const hasAffix = Boolean(num.prefix) || Boolean(num.suffix);
1397
+ if (!grouped && !hasAffix) {
1398
+ return /* @__PURE__ */ jsx5(
1399
+ TextInput,
1400
+ {
1401
+ field,
1402
+ value,
1403
+ error,
1404
+ setValue,
1405
+ api,
1406
+ ids: providedIds
1407
+ }
1408
+ );
1409
+ }
1410
+ const stored = typeof value === "string" || typeof value === "number" ? String(value) : "";
1411
+ const n = Number(stored);
1412
+ const display = draft !== null ? draft : grouped && stored !== "" && Number.isFinite(n) ? formatGroupedNumber(n, { locale, decimals: num.decimals }) : stored;
1413
+ const input = /* @__PURE__ */ jsx5(
1414
+ "input",
1415
+ {
1416
+ ref: inputRef,
1417
+ id: ids.inputId,
1418
+ ...control,
1419
+ type: "text",
1420
+ inputMode: "decimal",
1421
+ value: display,
1422
+ placeholder: field.placeholder,
1423
+ onBlur: grouped ? () => setDraft(null) : void 0,
1424
+ onChange: (e) => {
1425
+ if (!isValidPartialNumberText(e.target.value, locale)) {
1426
+ pendingCaret.current = e.target.selectionStart ?? e.target.value.length;
1427
+ setRevertTick((t) => t + 1);
1428
+ return;
1429
+ }
1430
+ if (grouped) {
1431
+ setDraft(e.target.value);
1432
+ setValue(parseGroupedNumber(e.target.value, locale));
1433
+ } else {
1434
+ setValue(e.target.value);
1435
+ }
1436
+ },
1437
+ ...fieldAria(field, error, ids)
1438
+ }
1439
+ );
1440
+ return /* @__PURE__ */ jsx5(FieldShell, { field, error, ids, children: hasAffix ? /* @__PURE__ */ jsxs5("div", { className: "fillo-number", children: [
1441
+ num.prefix && /* @__PURE__ */ jsx5("span", { className: "fillo-number-prefix", children: num.prefix }),
1442
+ input,
1443
+ num.suffix && /* @__PURE__ */ jsx5("span", { className: "fillo-number-suffix", children: num.suffix })
1444
+ ] }) : input });
1445
+ }
1265
1446
  function LongText({ field, value, error, setValue, ids: providedIds }) {
1266
1447
  const ids = useFilloFieldIds(field.id, providedIds);
1267
1448
  const control = useControl("fillo-input fillo-textarea", field, error);
@@ -1347,12 +1528,6 @@ function SingleChoice({ field, value, error, setValue, ids: providedIds }) {
1347
1528
  opt.id,
1348
1529
  value === opt.id
1349
1530
  ),
1350
- onClick: (event) => {
1351
- if (isNativeControlTarget(event.target)) return;
1352
- event.preventDefault();
1353
- setOtherOn(false);
1354
- setValue(opt.id);
1355
- },
1356
1531
  children: [
1357
1532
  /* @__PURE__ */ jsx5(
1358
1533
  "input",
@@ -1368,15 +1543,21 @@ function SingleChoice({ field, value, error, setValue, ids: providedIds }) {
1368
1543
  }
1369
1544
  ),
1370
1545
  /* @__PURE__ */ jsx5(ChoiceIcon, { icon: opt.icon }),
1546
+ opt.icon && value === opt.id && // Icon-mode hides the native input, so its check state is
1547
+ // otherwise color-only (forfeits forced-colors' own indicator).
1548
+ /* @__PURE__ */ jsx5("span", { className: "fillo-option-check", "aria-hidden": "true", children: "\u2713" }),
1371
1549
  /* @__PURE__ */ jsx5("span", { className: slotClass2("fillo-option-label", appearance, { slot: "optionLabel", kind: field.kind, fieldId: field.id }), "data-fillo": "optionLabel", children: opt.label })
1372
1550
  ]
1373
1551
  },
1374
1552
  opt.id
1375
1553
  )),
1376
- choice.allowOther && // A <div>, not a <label>, so editing the free-text input below can't
1377
- // toggle the radio. The radio + "Other" text keep their own <label>.
1554
+ choice.allowOther && // The outer element is a <label> so the whole row is a pointer
1555
+ // target (native label click-forwarding), same as every other
1556
+ // option row above. The free-text input below is its own labelable
1557
+ // descendant, so it handles its own clicks without re-toggling the
1558
+ // radio (dom's proven pattern).
1378
1559
  /* @__PURE__ */ jsxs5(
1379
- "div",
1560
+ "label",
1380
1561
  {
1381
1562
  "data-fillo": "option",
1382
1563
  "data-selected": otherActive ? "" : void 0,
@@ -1386,7 +1567,7 @@ function SingleChoice({ field, value, error, setValue, ids: providedIds }) {
1386
1567
  otherActive
1387
1568
  ),
1388
1569
  children: [
1389
- /* @__PURE__ */ jsxs5("label", { className: "fillo-option-main", children: [
1570
+ /* @__PURE__ */ jsxs5("span", { className: "fillo-option-main", children: [
1390
1571
  /* @__PURE__ */ jsx5(
1391
1572
  "input",
1392
1573
  {
@@ -1441,7 +1622,7 @@ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
1441
1622
  "data-fillo": "options",
1442
1623
  role: "group",
1443
1624
  "aria-labelledby": ids.labelId,
1444
- ...fieldAria(field, error, ids),
1625
+ ...fieldAria(field, error, ids, { required: false }),
1445
1626
  children: [
1446
1627
  options.map((opt) => /* @__PURE__ */ jsxs5(
1447
1628
  "label",
@@ -1454,11 +1635,6 @@ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
1454
1635
  opt.id,
1455
1636
  selected.includes(opt.id)
1456
1637
  ),
1457
- onClick: (event) => {
1458
- if (isNativeControlTarget(event.target)) return;
1459
- event.preventDefault();
1460
- toggle(opt.id);
1461
- },
1462
1638
  children: [
1463
1639
  /* @__PURE__ */ jsx5(
1464
1640
  "input",
@@ -1470,15 +1646,21 @@ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
1470
1646
  }
1471
1647
  ),
1472
1648
  /* @__PURE__ */ jsx5(ChoiceIcon, { icon: opt.icon }),
1649
+ opt.icon && selected.includes(opt.id) && // Icon-mode hides the native input, so its check state is
1650
+ // otherwise color-only (forfeits forced-colors' own indicator).
1651
+ /* @__PURE__ */ jsx5("span", { className: "fillo-option-check", "aria-hidden": "true", children: "\u2713" }),
1473
1652
  /* @__PURE__ */ jsx5("span", { className: slotClass2("fillo-option-label", appearance, { slot: "optionLabel", kind: field.kind, fieldId: field.id }), "data-fillo": "optionLabel", children: opt.label })
1474
1653
  ]
1475
1654
  },
1476
1655
  opt.id
1477
1656
  )),
1478
- choice.allowOther && // A <div>, not a <label>, so editing the free-text input below can't
1479
- // toggle the checkbox. The checkbox + "Other" text keep their own <label>.
1657
+ choice.allowOther && // The outer element is a <label> so the whole row is a pointer
1658
+ // target (native label click-forwarding), same as every other
1659
+ // option row above. The free-text input below is its own labelable
1660
+ // descendant, so it handles its own clicks without re-toggling the
1661
+ // checkbox (dom's proven pattern).
1480
1662
  /* @__PURE__ */ jsxs5(
1481
- "div",
1663
+ "label",
1482
1664
  {
1483
1665
  "data-fillo": "option",
1484
1666
  "data-selected": otherActive ? "" : void 0,
@@ -1488,7 +1670,7 @@ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
1488
1670
  otherActive
1489
1671
  ),
1490
1672
  children: [
1491
- /* @__PURE__ */ jsxs5("label", { className: "fillo-option-main", children: [
1673
+ /* @__PURE__ */ jsxs5("span", { className: "fillo-option-main", children: [
1492
1674
  /* @__PURE__ */ jsx5(
1493
1675
  "input",
1494
1676
  {
@@ -1610,18 +1792,16 @@ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
1610
1792
  {
1611
1793
  id: ids.inputId,
1612
1794
  type: "checkbox",
1613
- role: "switch",
1614
1795
  className: "fillo-toggle-input",
1615
1796
  checked: value === true,
1616
1797
  onChange: (e) => setValue(e.target.checked),
1617
- "aria-checked": value === true,
1618
1798
  ...fieldAria(field, error, ids)
1619
1799
  }
1620
1800
  ),
1621
1801
  /* @__PURE__ */ jsx5("span", { className: "fillo-toggle-track", "aria-hidden": "true", children: /* @__PURE__ */ jsx5("span", { className: "fillo-toggle-thumb" }) })
1622
1802
  ] }),
1623
1803
  field.description && /* @__PURE__ */ jsx5("p", { className: slots.description, "data-fillo": "fieldDescription", id: ids.descriptionId, children: field.description }),
1624
- error && /* @__PURE__ */ jsx5("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, role: "alert", children: error })
1804
+ error && /* @__PURE__ */ jsx5("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, children: error })
1625
1805
  ]
1626
1806
  }
1627
1807
  );
@@ -1651,28 +1831,21 @@ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
1651
1831
  ] })
1652
1832
  ] }),
1653
1833
  field.description && /* @__PURE__ */ jsx5("p", { className: slots.description, "data-fillo": "fieldDescription", id: ids.descriptionId, children: field.description }),
1654
- error && /* @__PURE__ */ jsx5("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, role: "alert", children: error })
1834
+ error && /* @__PURE__ */ jsx5("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, children: error })
1655
1835
  ]
1656
1836
  }
1657
1837
  );
1658
1838
  }
1659
1839
  function radiogroupKeyDown(e, steps, current, setValue) {
1660
- const forward = e.key === "ArrowRight" || e.key === "ArrowDown";
1661
- const backward = e.key === "ArrowLeft" || e.key === "ArrowUp";
1662
- if (!forward && !backward) return;
1840
+ const group = e.currentTarget;
1841
+ const rtl = getComputedStyle(group).direction === "rtl";
1842
+ const index = rovingIndex(steps, current);
1843
+ const next = radioGroupStep(e.key, index, steps.length, { rtl });
1844
+ if (next == null) return;
1663
1845
  e.preventDefault();
1664
- const index = current == null ? -1 : steps.indexOf(current);
1665
- let next;
1666
- if (index === -1) {
1667
- next = forward ? 0 : steps.length - 1;
1668
- } else {
1669
- next = forward ? index + 1 : index - 1;
1670
- next = (next + steps.length) % steps.length;
1671
- }
1672
1846
  const value = steps[next];
1673
1847
  if (value === void 0) return;
1674
1848
  setValue(value);
1675
- const group = e.currentTarget;
1676
1849
  const buttons = group.querySelectorAll('[role="radio"]');
1677
1850
  buttons[next]?.focus();
1678
1851
  }
@@ -1693,6 +1866,7 @@ function Rating({ field, value, error, setValue, ids: providedIds }) {
1693
1866
  return /* @__PURE__ */ jsx5(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx5(
1694
1867
  "div",
1695
1868
  {
1869
+ id: ids.inputId,
1696
1870
  ...control,
1697
1871
  role: "radiogroup",
1698
1872
  "aria-labelledby": ids.labelId,
@@ -1714,8 +1888,11 @@ function Rating({ field, value, error, setValue, ids: providedIds }) {
1714
1888
  "aria-label": `${n} of ${max}`,
1715
1889
  "aria-checked": n === current,
1716
1890
  tabIndex: i === tabbable ? 0 : -1,
1717
- onClick: () => setValue(n === current ? null : n),
1718
- children: "\u2605"
1891
+ onClick: (event) => {
1892
+ if (event.detail === 0 && n === current) return;
1893
+ setValue(n === current ? null : n);
1894
+ },
1895
+ children: n <= current ? "\u2605" : "\u2606"
1719
1896
  },
1720
1897
  n
1721
1898
  ))
@@ -1745,6 +1922,7 @@ function LinearScale({ field, value, error, setValue, ids: providedIds }) {
1745
1922
  /* @__PURE__ */ jsx5(
1746
1923
  "div",
1747
1924
  {
1925
+ id: ids.inputId,
1748
1926
  ...control,
1749
1927
  role: "radiogroup",
1750
1928
  "aria-labelledby": ids.labelId,
@@ -1768,7 +1946,10 @@ function LinearScale({ field, value, error, setValue, ids: providedIds }) {
1768
1946
  "aria-label": String(n),
1769
1947
  "aria-checked": value === n,
1770
1948
  tabIndex: i === tabbable ? 0 : -1,
1771
- onClick: () => setValue(value === n ? null : n),
1949
+ onClick: (event) => {
1950
+ if (event.detail === 0 && n === current) return;
1951
+ setValue(value === n ? null : n);
1952
+ },
1772
1953
  children: n
1773
1954
  },
1774
1955
  n
@@ -1783,19 +1964,34 @@ function LinearScale({ field, value, error, setValue, ids: providedIds }) {
1783
1964
  }
1784
1965
  function Ranking({ field, value, error, setValue, ids: providedIds }) {
1785
1966
  const ids = useFilloFieldIds(field.id, providedIds);
1967
+ const strings = useStrings();
1968
+ const announce = useFilloAnnounce();
1786
1969
  const ranking = field;
1787
1970
  const answered = Array.isArray(value) ? value : [];
1788
1971
  const order = [
1789
1972
  ...answered.filter((id) => ranking.options.some((o) => o.id === id)),
1790
1973
  ...ranking.options.map((o) => o.id).filter((id) => !answered.includes(id))
1791
1974
  ];
1792
- function move(id, delta) {
1975
+ const pressedRef = useRef6(null);
1976
+ useEffect6(() => {
1977
+ const pressed = pressedRef.current;
1978
+ pressedRef.current = null;
1979
+ if (!pressed || !pressed.disabled) return;
1980
+ const sibling = pressed.parentElement?.querySelector(
1981
+ ".fillo-ranking-move:not(:disabled)"
1982
+ );
1983
+ sibling?.focus();
1984
+ }, [value]);
1985
+ function move(id, delta, button) {
1793
1986
  const index = order.indexOf(id);
1794
1987
  const target = index + delta;
1795
1988
  if (target < 0 || target >= order.length) return;
1989
+ pressedRef.current = button;
1796
1990
  const next = [...order];
1797
1991
  [next[index], next[target]] = [next[target], next[index]];
1798
1992
  setValue(next);
1993
+ const moved = ranking.options.find((o) => o.id === id);
1994
+ if (moved) announce(strings.rankingPosition(moved.label, target + 1, order.length));
1799
1995
  }
1800
1996
  return /* @__PURE__ */ jsx5(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx5(
1801
1997
  "div",
@@ -1803,7 +1999,7 @@ function Ranking({ field, value, error, setValue, ids: providedIds }) {
1803
1999
  id: ids.inputId,
1804
2000
  role: "group",
1805
2001
  "aria-labelledby": ids.labelId,
1806
- ...fieldAria(field, error, ids),
2002
+ ...fieldAria(field, error, ids, { required: false }),
1807
2003
  children: /* @__PURE__ */ jsx5("ol", { className: "fillo-ranking", children: order.map((id, index) => {
1808
2004
  const option = ranking.options.find((o) => o.id === id);
1809
2005
  if (!option) return null;
@@ -1818,7 +2014,7 @@ function Ranking({ field, value, error, setValue, ids: providedIds }) {
1818
2014
  className: "fillo-ranking-move",
1819
2015
  "aria-label": `Move ${option.label} up`,
1820
2016
  disabled: index === 0,
1821
- onClick: () => move(id, -1),
2017
+ onClick: (e) => move(id, -1, e.currentTarget),
1822
2018
  children: "\u2191"
1823
2019
  }
1824
2020
  ),
@@ -1829,7 +2025,7 @@ function Ranking({ field, value, error, setValue, ids: providedIds }) {
1829
2025
  className: "fillo-ranking-move",
1830
2026
  "aria-label": `Move ${option.label} down`,
1831
2027
  disabled: index === order.length - 1,
1832
- onClick: () => move(id, 1),
2028
+ onClick: (e) => move(id, 1, e.currentTarget),
1833
2029
  children: "\u2193"
1834
2030
  }
1835
2031
  )
@@ -1850,10 +2046,10 @@ function Matrix({ field, value, error, setValue, ids: providedIds }) {
1850
2046
  className: "fillo-matrix-wrap",
1851
2047
  role: "group",
1852
2048
  "aria-labelledby": ids.labelId,
1853
- ...fieldAria(field, error, ids),
2049
+ ...fieldAria(field, error, ids, { required: false }),
1854
2050
  children: /* @__PURE__ */ jsxs5("table", { className: "fillo-matrix", children: [
1855
2051
  /* @__PURE__ */ jsx5("thead", { children: /* @__PURE__ */ jsxs5("tr", { children: [
1856
- /* @__PURE__ */ jsx5("th", {}),
2052
+ /* @__PURE__ */ jsx5("td", {}),
1857
2053
  matrix.columns.map((col) => /* @__PURE__ */ jsx5("th", { scope: "col", children: col.label }, col.id))
1858
2054
  ] }) }),
1859
2055
  /* @__PURE__ */ jsx5("tbody", { children: matrix.rows.map((row) => {
@@ -1872,12 +2068,12 @@ function Matrix({ field, value, error, setValue, ids: providedIds }) {
1872
2068
  "div",
1873
2069
  {
1874
2070
  role: "radiogroup",
1875
- "aria-labelledby": rowLabelId,
2071
+ "aria-labelledby": `${ids.labelId} ${rowLabelId}`,
1876
2072
  "aria-owns": matrix.columns.map(radioId).join(" "),
1877
2073
  children: /* @__PURE__ */ jsx5("span", { id: rowLabelId, children: row.label })
1878
2074
  }
1879
2075
  ) }),
1880
- matrix.columns.map((col) => /* @__PURE__ */ jsx5("td", { children: /* @__PURE__ */ jsx5(
2076
+ matrix.columns.map((col) => /* @__PURE__ */ jsx5("td", { "data-label": col.label, children: /* @__PURE__ */ jsx5("label", { className: "fillo-matrix-cell", children: /* @__PURE__ */ jsx5(
1881
2077
  "input",
1882
2078
  {
1883
2079
  id: radioId(col),
@@ -1890,7 +2086,7 @@ function Matrix({ field, value, error, setValue, ids: providedIds }) {
1890
2086
  checked: answers[row.id] === col.id,
1891
2087
  onChange: () => setValue({ ...answers, [row.id]: col.id })
1892
2088
  }
1893
- ) }, col.id))
2089
+ ) }) }, col.id))
1894
2090
  ] }, row.id)
1895
2091
  );
1896
2092
  }) })
@@ -1901,12 +2097,45 @@ function Matrix({ field, value, error, setValue, ids: providedIds }) {
1901
2097
  function Hidden() {
1902
2098
  return null;
1903
2099
  }
2100
+ function Calculated({ field, value, ids: providedIds }) {
2101
+ const ids = useFilloFieldIds(field.id, providedIds);
2102
+ const state = { kind: field.kind, fieldId: field.id, invalid: false, required: false };
2103
+ const rootCls = useSlotClass("fillo-field fillo-field--calculated fillo-calculated", {
2104
+ slot: "calculated",
2105
+ ...state
2106
+ });
2107
+ const labelCls = useSlotClass("fillo-label", { slot: "label", ...state });
2108
+ const descriptionCls = useSlotClass("fillo-description", { slot: "fieldDescription", ...state });
2109
+ const answered = value !== void 0 && value !== null && value !== "";
2110
+ return /* @__PURE__ */ jsxs5(
2111
+ "div",
2112
+ {
2113
+ className: rootCls,
2114
+ "data-fillo": "calculated",
2115
+ "data-field": field.id,
2116
+ "data-kind": field.kind,
2117
+ children: [
2118
+ /* @__PURE__ */ jsx5("label", { className: labelCls, "data-fillo": "label", id: ids.labelId, htmlFor: ids.inputId, children: field.label }),
2119
+ field.description && /* @__PURE__ */ jsx5("p", { className: descriptionCls, "data-fillo": "fieldDescription", id: ids.descriptionId, children: field.description }),
2120
+ /* @__PURE__ */ jsx5(
2121
+ "output",
2122
+ {
2123
+ id: ids.inputId,
2124
+ className: `fillo-calculated-value${answered ? "" : " fillo-calculated-value--empty"}`,
2125
+ "aria-describedby": field.description ? ids.descriptionId : void 0,
2126
+ children: answered ? formatAnswer(field, value) : "\u2014"
2127
+ }
2128
+ )
2129
+ ]
2130
+ }
2131
+ );
2132
+ }
1904
2133
  var DEFAULT_COMPONENTS = {
1905
2134
  short_text: TextInput,
1906
2135
  email: TextInput,
1907
2136
  url: TextInput,
1908
2137
  phone: PhoneField,
1909
- number: TextInput,
2138
+ number: NumberInput,
1910
2139
  date: TextInput,
1911
2140
  long_text: LongText,
1912
2141
  select: SingleChoice,
@@ -1919,7 +2148,8 @@ var DEFAULT_COMPONENTS = {
1919
2148
  matrix: Matrix,
1920
2149
  signature: SignatureField,
1921
2150
  file_upload: FileUploadField,
1922
- hidden: Hidden
2151
+ hidden: Hidden,
2152
+ calculated: Calculated
1923
2153
  };
1924
2154
  function ContentRenderer({ block }) {
1925
2155
  switch (block.kind) {
@@ -2034,7 +2264,7 @@ function FormField({
2034
2264
  }
2035
2265
 
2036
2266
  // src/turnstile.tsx
2037
- import { useEffect as useEffect7, useRef as useRef6 } from "react";
2267
+ import { useEffect as useEffect7, useRef as useRef7 } from "react";
2038
2268
  import { jsx as jsx6 } from "react/jsx-runtime";
2039
2269
  var SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
2040
2270
  var SCRIPT_ID = "fillo-turnstile-script";
@@ -2093,13 +2323,13 @@ function TurnstileWidget({
2093
2323
  registerReset,
2094
2324
  className
2095
2325
  }) {
2096
- const containerRef = useRef6(null);
2097
- const widgetIdRef = useRef6(null);
2098
- const onTokenRef = useRef6(onToken);
2326
+ const containerRef = useRef7(null);
2327
+ const widgetIdRef = useRef7(null);
2328
+ const onTokenRef = useRef7(onToken);
2099
2329
  onTokenRef.current = onToken;
2100
- const onErrorRef = useRef6(onError);
2330
+ const onErrorRef = useRef7(onError);
2101
2331
  onErrorRef.current = onError;
2102
- const registerResetRef = useRef6(registerReset);
2332
+ const registerResetRef = useRef7(registerReset);
2103
2333
  registerResetRef.current = registerReset;
2104
2334
  useEffect7(() => {
2105
2335
  let cancelled = false;
@@ -2155,7 +2385,7 @@ function TurnstileWidget({
2155
2385
  // src/FilloForm.tsx
2156
2386
  import { Fragment as Fragment3, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
2157
2387
  function themeProps(theme) {
2158
- const safeTheme = normalizeFormTheme(theme);
2388
+ const safeTheme = resolveThemeAppearance(normalizeFormTheme(theme));
2159
2389
  if (!safeTheme) return {};
2160
2390
  const style = {};
2161
2391
  if (safeTheme.primary) style["--fillo-primary"] = safeTheme.primary;
@@ -2175,17 +2405,25 @@ function badgeUrl(href) {
2175
2405
  url.searchParams.set("utm_source", "form_badge");
2176
2406
  return url.toString();
2177
2407
  }
2178
- function focusFirstInvalid(formEl) {
2179
- if (!formEl || typeof window === "undefined") return;
2180
- const run = () => {
2181
- const invalid = formEl.querySelector('[aria-invalid="true"]');
2182
- const focusable = 'input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])';
2183
- const target = invalid?.matches(focusable) ? invalid : invalid?.querySelector(focusable);
2184
- target?.focus?.();
2185
- };
2408
+ var FOCUSABLE_SELECTOR = 'input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])';
2409
+ function resolveFocusable(el) {
2410
+ if (!el) return null;
2411
+ return el.matches(FOCUSABLE_SELECTOR) ? el : el.querySelector(FOCUSABLE_SELECTOR);
2412
+ }
2413
+ function nextFrame(run) {
2186
2414
  if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
2187
2415
  else setTimeout(run, 0);
2188
2416
  }
2417
+ function focusErrorSummary(formEl) {
2418
+ if (!formEl || typeof window === "undefined") return;
2419
+ nextFrame(() => {
2420
+ formEl.querySelector(".fillo-error-summary")?.focus();
2421
+ });
2422
+ }
2423
+ function focusField(inputId) {
2424
+ if (typeof document === "undefined") return;
2425
+ resolveFocusable(document.getElementById(inputId))?.focus();
2426
+ }
2189
2427
  var HP_STYLE = {
2190
2428
  position: "absolute",
2191
2429
  left: "-9999px",
@@ -2204,12 +2442,15 @@ function assertHpHidden(el) {
2204
2442
  }
2205
2443
  function FilloForm(props) {
2206
2444
  const { form, formId } = props;
2445
+ const renderOnly = props.renderOnly === true || props.skipValidation === true;
2207
2446
  const client = useMemo5(
2208
- () => props.client ?? (formId ? createClient() : void 0),
2209
- [props.client, formId]
2447
+ () => renderOnly ? void 0 : props.client ?? (formId ? createClient() : void 0),
2448
+ [renderOnly, props.client, formId]
2210
2449
  );
2211
2450
  const [loaded, setLoaded] = useState6(null);
2212
- const [loadFailure, setLoadFailure] = useState6(null);
2451
+ const [loadFailure, setLoadFailure] = useState6(
2452
+ null
2453
+ );
2213
2454
  const devEnv = useIsDevEnv();
2214
2455
  const devChrome = props.preview === true || devEnv;
2215
2456
  if (props.preview === true) warnPreviewInProduction();
@@ -2227,6 +2468,7 @@ function FilloForm(props) {
2227
2468
  accepting: syncedAccepting,
2228
2469
  acceptingReason: syncedAcceptingReason,
2229
2470
  uploadsAvailable: syncedUploadsAvailable,
2471
+ uploadFileSizeLimitMb: syncedUploadFileSizeLimitMb,
2230
2472
  resolvedSchema,
2231
2473
  resolvedTheme,
2232
2474
  warningCode: syncedWarningCode,
@@ -2238,10 +2480,22 @@ function FilloForm(props) {
2238
2480
  });
2239
2481
  const renderResolvedSnapshot = Boolean(resolvedSchema && !devChrome);
2240
2482
  const inlineSchema = codeForm ? renderResolvedSnapshot ? resolvedSchema : codeForm.schema : form;
2241
- const onErrorRef = useRef7(onError);
2483
+ const onErrorRef = useRef8(onError);
2242
2484
  useEffect8(() => {
2243
2485
  onErrorRef.current = onError;
2244
2486
  });
2487
+ const targetError = useMemo5(
2488
+ () => form && !formId && !renderOnly && (!isCodeForm2(form) || !props.client) ? new FilloError2(
2489
+ "This form has no Fillo target. Pass formId with the schema, use a defineForm() value with client, or set renderOnly for a non-submitting preview.",
2490
+ 422,
2491
+ void 0,
2492
+ "form_target_required"
2493
+ ) : null,
2494
+ [form, formId, props.client, renderOnly]
2495
+ );
2496
+ useEffect8(() => {
2497
+ if (targetError) onErrorRef.current?.(targetError);
2498
+ }, [targetError]);
2245
2499
  const needsFetch = !inlineSchema && Boolean(client && formId);
2246
2500
  useEffect8(() => {
2247
2501
  if (!needsFetch || !client || !formId) {
@@ -2262,6 +2516,7 @@ function FilloForm(props) {
2262
2516
  accepting: published.accepting,
2263
2517
  acceptingReason: published.acceptingReason,
2264
2518
  uploadsAvailable: published.uploadsAvailable,
2519
+ uploadFileSizeLimitMb: published.uploadFileSizeLimitMb,
2265
2520
  branding: published.branding ?? null,
2266
2521
  challenge: published.challenge ?? null
2267
2522
  });
@@ -2279,10 +2534,28 @@ function FilloForm(props) {
2279
2534
  const currentLoaded = needsFetch && loaded?.formId === formId ? loaded : null;
2280
2535
  const loadError = needsFetch && loadFailure && loadFailure.formId === formId ? loadFailure.error : null;
2281
2536
  const rawSchema = inlineSchema ?? currentLoaded?.schema ?? null;
2282
- const normalized = useMemo5(() => rawSchema ? normalizeFormSchema(rawSchema) : null, [rawSchema]);
2537
+ const normalized = useMemo5(
2538
+ () => rawSchema ? normalizeFormSchema(rawSchema) : null,
2539
+ [rawSchema]
2540
+ );
2283
2541
  const schema = normalized?.ok ? normalized.schema : null;
2284
2542
  const resolvedCodeTheme = renderResolvedSnapshot ? resolvedTheme ?? void 0 : codeForm?.theme;
2285
2543
  const theme = props.appearance?.theme ?? props.theme ?? resolvedCodeTheme ?? currentLoaded?.theme ?? void 0;
2544
+ if (targetError) {
2545
+ if (props.renderError) return /* @__PURE__ */ jsx7(Fragment3, { children: props.renderError(targetError) });
2546
+ return /* @__PURE__ */ jsx7(
2547
+ "div",
2548
+ {
2549
+ className: `fillo-form fillo-form--error ${props.className ?? ""}`,
2550
+ role: "alert",
2551
+ "data-fillo": "root",
2552
+ "data-state": "error",
2553
+ "data-error-code": "form_target_required",
2554
+ ...themeProps(theme),
2555
+ children: devChrome ? `${targetError.message} (${targetError.code})` : strings.loadFailed
2556
+ }
2557
+ );
2558
+ }
2286
2559
  if (normalized && !normalized.ok) {
2287
2560
  const error = new FilloError2(`This form could not be rendered: ${normalized.error}`, 422);
2288
2561
  if (props.renderError) return /* @__PURE__ */ jsx7(Fragment3, { children: props.renderError(error) });
@@ -2323,7 +2596,14 @@ function FilloForm(props) {
2323
2596
  );
2324
2597
  }
2325
2598
  if (currentLoaded?.closed) {
2326
- return /* @__PURE__ */ jsx7("div", { className: `fillo-form fillo-form--closed ${props.className ?? ""}`, ...themeProps(theme), children: /* @__PURE__ */ jsx7("p", { className: "fillo-closed", children: strings.closed }) });
2599
+ return /* @__PURE__ */ jsx7(
2600
+ "div",
2601
+ {
2602
+ className: `fillo-form fillo-form--closed ${props.className ?? ""}`,
2603
+ ...themeProps(theme),
2604
+ children: /* @__PURE__ */ jsx7("p", { className: "fillo-closed", children: strings.closed })
2605
+ }
2606
+ );
2327
2607
  }
2328
2608
  const canonicalSyncError = codeForm && syncError && !props.skipValidation ? syncError : null;
2329
2609
  if (canonicalSyncError && props.renderError) {
@@ -2365,7 +2645,14 @@ function FilloForm(props) {
2365
2645
  }
2366
2646
  );
2367
2647
  }
2368
- return /* @__PURE__ */ jsx7("div", { className: `fillo-form fillo-form--closed ${props.className ?? ""}`, ...themeProps(theme), children: /* @__PURE__ */ jsx7("p", { className: "fillo-closed", children: variant === "closed" ? strings.closed : strings.notLive }) });
2648
+ return /* @__PURE__ */ jsx7(
2649
+ "div",
2650
+ {
2651
+ className: `fillo-form fillo-form--closed ${props.className ?? ""}`,
2652
+ ...themeProps(theme),
2653
+ children: /* @__PURE__ */ jsx7("p", { className: "fillo-closed", children: variant === "closed" ? strings.closed : strings.notLive })
2654
+ }
2655
+ );
2369
2656
  }
2370
2657
  const awaitingCanonical = Boolean(codeForm && codeFormSyncing && !props.skipValidation);
2371
2658
  if (!devChrome && awaitingCanonical || !schema) {
@@ -2390,10 +2677,12 @@ function FilloForm(props) {
2390
2677
  ResolvedForm,
2391
2678
  {
2392
2679
  ...props,
2393
- client,
2680
+ client: renderOnly ? void 0 : client,
2394
2681
  schema,
2395
2682
  theme,
2396
- formId: syncedFormId ?? formId,
2683
+ formId: renderOnly ? void 0 : syncedFormId ?? formId,
2684
+ renderOnly,
2685
+ skipValidation: renderOnly ? true : props.skipValidation,
2397
2686
  poweredBy,
2398
2687
  brandLabel: branding?.label,
2399
2688
  brandHref: branding?.href,
@@ -2401,6 +2690,7 @@ function FilloForm(props) {
2401
2690
  stagedNotice: codeChangesStaged,
2402
2691
  syncErrorNotice: syncError ?? syncNoticeError ?? void 0,
2403
2692
  uploadsAvailable: codeForm ? syncedUploadsAvailable : currentLoaded?.uploadsAvailable,
2693
+ uploadFileSizeLimitMb: codeForm ? syncedUploadFileSizeLimitMb : currentLoaded?.uploadFileSizeLimitMb ?? props.uploadFileSizeLimitMb,
2404
2694
  syncWarningCode: codeForm ? syncedWarningCode : void 0,
2405
2695
  syncWarningUrl: syncedWarningUrl,
2406
2696
  challenge
@@ -2463,20 +2753,21 @@ function useIsDevEnv() {
2463
2753
  function ResolvedForm(props) {
2464
2754
  const { schema, theme } = props;
2465
2755
  const displayOnly = props.displayOnly === true;
2756
+ const renderOnly = props.renderOnly === true;
2466
2757
  const instanceId = useId2().replace(/:/g, "");
2467
2758
  const devEnv = useIsDevEnv();
2468
2759
  const devChrome = !displayOnly && (props.preview === true || devEnv);
2469
- const hpRef = useRef7(null);
2760
+ const hpRef = useRef8(null);
2470
2761
  const codeForm = isCodeForm2(props.form) ? props.form : null;
2471
2762
  const client = props.client;
2472
- const challenge = props.challenge;
2763
+ const challenge = renderOnly ? void 0 : props.challenge;
2473
2764
  const challengeRequired = Boolean(challenge);
2474
- const challengeTokenRef = useRef7(void 0);
2765
+ const challengeTokenRef = useRef8(void 0);
2475
2766
  const [resyncWarning, setResyncWarning] = useState6(null);
2476
2767
  const [resolutionFailureCode, setResolutionFailureCode] = useState6(null);
2477
2768
  const [challengeToken, setChallengeToken] = useState6(void 0);
2478
2769
  const [challengeError, setChallengeError] = useState6(false);
2479
- const resetChallengeRef = useRef7(void 0);
2770
+ const resetChallengeRef = useRef8(void 0);
2480
2771
  const handleChallengeToken = useCallback((token) => {
2481
2772
  challengeTokenRef.current = token;
2482
2773
  setChallengeToken(token);
@@ -2494,7 +2785,8 @@ function ResolvedForm(props) {
2494
2785
  setResyncWarning({
2495
2786
  code: result.warningCode,
2496
2787
  url: result.warningUrl,
2497
- uploadsAvailable: result.uploadsAvailable
2788
+ uploadsAvailable: result.uploadsAvailable,
2789
+ uploadFileSizeLimitMb: result.uploadFileSizeLimitMb
2498
2790
  });
2499
2791
  if (result.status === "draft") {
2500
2792
  throw new FilloError2(
@@ -2580,19 +2872,44 @@ function ResolvedForm(props) {
2580
2872
  resetChallengeRef.current?.();
2581
2873
  }
2582
2874
  });
2583
- useDraftFlush(api, !displayOnly);
2875
+ useDraftFlush(api, !displayOnly && !renderOnly);
2876
+ const strings = resolveStrings(props.strings);
2877
+ const announceRef = useRef8(null);
2878
+ const announce = useCallback((text) => {
2879
+ const el = announceRef.current;
2880
+ if (!el) return;
2881
+ if (el.textContent === text) {
2882
+ el.textContent = "";
2883
+ nextFrame(() => {
2884
+ if (announceRef.current) announceRef.current.textContent = text;
2885
+ });
2886
+ return;
2887
+ }
2888
+ el.textContent = text;
2889
+ }, []);
2890
+ useEffect8(() => {
2891
+ if (api.status === "submitting") announce(strings.submittingAnnouncement);
2892
+ }, [api.status, announce, strings.submittingAnnouncement]);
2893
+ const resuming = api.resumedDraft || api.editingPrevious;
2894
+ const resumeAnnouncement = api.editingPrevious ? strings.editNotice : strings.resumeNotice;
2895
+ useEffect8(() => {
2896
+ if (resuming) announce(resumeAnnouncement);
2897
+ }, [resuming, resumeAnnouncement, announce]);
2584
2898
  const themed = useMemo5(() => themeProps(theme), [theme]);
2585
2899
  const warningCode = resyncWarning ? resyncWarning.code : props.syncWarningCode;
2586
2900
  const warningUrl = resyncWarning ? resyncWarning.url : props.syncWarningUrl;
2587
2901
  const uploadsAvailable = resyncWarning ? resyncWarning.uploadsAvailable : props.uploadsAvailable;
2902
+ const uploadFileSizeLimitMb = resyncWarning ? resyncWarning.uploadFileSizeLimitMb : props.uploadFileSizeLimitMb;
2588
2903
  const chromeValue = useMemo5(
2589
2904
  () => ({
2590
2905
  devChrome,
2906
+ renderOnly,
2591
2907
  uploadsAvailable,
2908
+ uploadFileSizeLimitMb,
2592
2909
  warningCode,
2593
2910
  warningUrl
2594
2911
  }),
2595
- [devChrome, uploadsAvailable, warningCode, warningUrl]
2912
+ [devChrome, renderOnly, uploadsAvailable, uploadFileSizeLimitMb, warningCode, warningUrl]
2596
2913
  );
2597
2914
  const settings = schema.settings;
2598
2915
  const submitted = api.status === "submitted";
@@ -2602,19 +2919,23 @@ function ResolvedForm(props) {
2602
2919
  const url = safeHttpUrl(settings.redirectUrl);
2603
2920
  if (url) window.location.assign(url);
2604
2921
  }, [liveSubmit, settings.redirectUrl]);
2605
- const successRef = useRef7(null);
2922
+ const successRef = useRef8(null);
2606
2923
  useEffect8(() => {
2607
2924
  if (liveSubmit) successRef.current?.focus();
2608
2925
  }, [liveSubmit]);
2609
- const blocksRef = useRef7(null);
2610
- const prevPageIndex = useRef7(api.pageIndex);
2926
+ const blocksRef = useRef8(null);
2927
+ const prevPageIndex = useRef8(api.pageIndex);
2611
2928
  useEffect8(() => {
2612
2929
  if (prevPageIndex.current === api.pageIndex) return;
2613
2930
  prevPageIndex.current = api.pageIndex;
2614
- blocksRef.current?.focus();
2931
+ const container = blocksRef.current?.parentElement;
2932
+ const heading = container?.querySelector(".fillo-page-title, .fillo-title");
2933
+ const target = heading ?? blocksRef.current;
2934
+ if (!target) return;
2935
+ if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");
2936
+ target.focus();
2615
2937
  }, [api.pageIndex]);
2616
2938
  const appearance = props.appearance;
2617
- const strings = resolveStrings(props.strings);
2618
2939
  if (api.status === "submitted") {
2619
2940
  return /* @__PURE__ */ jsxs6(
2620
2941
  "div",
@@ -2629,16 +2950,20 @@ function ResolvedForm(props) {
2629
2950
  { slot: "root", status: "submitted" }
2630
2951
  ),
2631
2952
  "data-fillo": "root",
2953
+ "data-fillo-form-id": props.formId,
2632
2954
  "data-state": "submitted",
2633
2955
  ...themed,
2634
2956
  children: [
2635
2957
  props.renderSuccess ? props.renderSuccess() : /* @__PURE__ */ jsxs6(
2636
2958
  "div",
2637
2959
  {
2638
- className: slotClass3("fillo-success", appearance, { slot: "success", status: "submitted" }),
2960
+ className: slotClass3("fillo-success", appearance, {
2961
+ slot: "success",
2962
+ status: "submitted"
2963
+ }),
2639
2964
  "data-fillo": "success",
2640
2965
  children: [
2641
- /* @__PURE__ */ jsx7("div", { className: "fillo-success-mark", "aria-hidden": "true", children: "\u2713" }),
2966
+ /* @__PURE__ */ jsx7("div", { className: "fillo-success-mark", "aria-hidden": "true" }),
2642
2967
  /* @__PURE__ */ jsx7("h2", { className: "fillo-success-title", children: settings.successTitle ?? strings.successTitle }),
2643
2968
  /* @__PURE__ */ jsx7("p", { className: "fillo-success-message", children: api.duplicateSubmission ? strings.alreadyAnswered : settings.successMessage ?? strings.successMessage })
2644
2969
  ]
@@ -2661,11 +2986,32 @@ function ResolvedForm(props) {
2661
2986
  const progressPos = progressSeq.indexOf(api.page.id);
2662
2987
  const progressStep = progressPos >= 0 ? progressPos + 1 : Math.min(api.pageIndex + 1, progressTotal);
2663
2988
  const submitFixUrl = devChrome && resolutionFailureCode === "form_not_published" && warningCode === "storage_required" ? safeHttpUrl(warningUrl) : null;
2989
+ const invalidFields = [];
2990
+ for (const block of api.blocks) {
2991
+ if (!isField2(block)) continue;
2992
+ const raw = api.errors[block.id];
2993
+ if (!raw) continue;
2994
+ invalidFields.push({
2995
+ field: block,
2996
+ error: raw === REQUIRED_FIELD_MESSAGE2 ? strings.required : raw,
2997
+ inputId: createFilloFieldIds(instanceId, block.id).inputId
2998
+ });
2999
+ }
2664
3000
  const rootClass = slotClass3(`fillo-form ${props.className ?? ""}`, appearance, {
2665
3001
  slot: "root",
2666
3002
  status: api.status
2667
3003
  });
2668
3004
  const formBody = /* @__PURE__ */ jsxs6(Fragment3, { children: [
3005
+ /* @__PURE__ */ jsx7(
3006
+ "span",
3007
+ {
3008
+ ref: announceRef,
3009
+ className: "fillo-sr-only",
3010
+ role: "status",
3011
+ "aria-live": "polite",
3012
+ "data-fillo": "announce"
3013
+ }
3014
+ ),
2669
3015
  devChrome && /* @__PURE__ */ jsx7(
2670
3016
  DevChrome,
2671
3017
  {
@@ -2675,15 +3021,14 @@ function ResolvedForm(props) {
2675
3021
  staged: props.stagedNotice,
2676
3022
  draft: props.draftNotice,
2677
3023
  warningUrl: props.syncWarningUrl,
2678
- noClient: !props.client && !props.skipValidation
3024
+ noClient: !props.client && !props.skipValidation && !renderOnly
2679
3025
  }
2680
3026
  ),
2681
- (api.resumedDraft || api.editingPrevious) && /* @__PURE__ */ jsxs6(
3027
+ resuming && /* @__PURE__ */ jsxs6(
2682
3028
  "div",
2683
3029
  {
2684
3030
  className: slotClass3("fillo-resume", appearance, { slot: "resume" }),
2685
3031
  "data-fillo": "resume",
2686
- role: "status",
2687
3032
  children: [
2688
3033
  /* @__PURE__ */ jsx7("span", { className: "fillo-resume-text", children: api.editingPrevious ? strings.editNotice : strings.resumeNotice }),
2689
3034
  /* @__PURE__ */ jsx7("button", { type: "button", className: "fillo-resume-clear", onClick: api.resetDraft, children: strings.resumeStartOver })
@@ -2693,7 +3038,9 @@ function ResolvedForm(props) {
2693
3038
  api.resumeLinkFailed && !api.resumedDraft && !api.editingPrevious && /* @__PURE__ */ jsx7(
2694
3039
  "div",
2695
3040
  {
2696
- className: slotClass3("fillo-resume fillo-resume--expired", appearance, { slot: "resume" }),
3041
+ className: slotClass3("fillo-resume fillo-resume--expired", appearance, {
3042
+ slot: "resume"
3043
+ }),
2697
3044
  "data-fillo": "resume",
2698
3045
  role: "status",
2699
3046
  children: /* @__PURE__ */ jsx7("span", { className: "fillo-resume-text", children: strings.resumeLinkExpired })
@@ -2714,33 +3061,83 @@ function ResolvedForm(props) {
2714
3061
  {
2715
3062
  className: slotClass3("fillo-progress-fill", appearance, { slot: "progressFill" }),
2716
3063
  "data-fillo": "progressFill",
2717
- style: { "--fillo-progress-value": `${progressStep / progressTotal * 100}%` }
3064
+ style: {
3065
+ "--fillo-progress-value": `${progressStep / progressTotal * 100}%`
3066
+ }
2718
3067
  }
2719
3068
  )
2720
3069
  }
2721
3070
  ),
2722
- api.pageIndex === 0 && props.showTitle !== false && /* @__PURE__ */ jsxs6("header", { className: slotClass3("fillo-header", appearance, { slot: "header" }), "data-fillo": "header", children: [
2723
- schema.title && /* @__PURE__ */ jsx7("h1", { className: slotClass3("fillo-title", appearance, { slot: "title" }), "data-fillo": "title", children: schema.title }),
2724
- schema.description && /* @__PURE__ */ jsx7(
2725
- "p",
3071
+ api.pageIndex === 0 && props.showTitle !== false && /* @__PURE__ */ jsxs6(
3072
+ "header",
3073
+ {
3074
+ className: slotClass3("fillo-header", appearance, { slot: "header" }),
3075
+ "data-fillo": "header",
3076
+ children: [
3077
+ schema.title && /* @__PURE__ */ jsx7(
3078
+ "h1",
3079
+ {
3080
+ className: slotClass3("fillo-title", appearance, { slot: "title" }),
3081
+ "data-fillo": "title",
3082
+ children: schema.title
3083
+ }
3084
+ ),
3085
+ schema.description && /* @__PURE__ */ jsx7(
3086
+ "p",
3087
+ {
3088
+ className: slotClass3("fillo-form-description", appearance, { slot: "description" }),
3089
+ "data-fillo": "description",
3090
+ children: schema.description
3091
+ }
3092
+ )
3093
+ ]
3094
+ }
3095
+ ),
3096
+ multiPage && api.page.title && api.pageIndex > 0 && /* @__PURE__ */ jsx7(
3097
+ "h2",
3098
+ {
3099
+ className: slotClass3("fillo-page-title", appearance, { slot: "pageTitle" }),
3100
+ "data-fillo": "pageTitle",
3101
+ children: api.page.title
3102
+ }
3103
+ ),
3104
+ invalidFields.length > 0 && /* @__PURE__ */ jsxs6("div", { className: "fillo-error-summary", role: "alert", tabIndex: -1, "data-fillo": "error-summary", children: [
3105
+ /* @__PURE__ */ jsx7("p", { className: "fillo-error-summary-title", children: strings.errorSummaryTitle }),
3106
+ /* @__PURE__ */ jsx7("ul", { children: invalidFields.map(({ field, error, inputId }) => /* @__PURE__ */ jsx7("li", { children: /* @__PURE__ */ jsxs6(
3107
+ "a",
2726
3108
  {
2727
- className: slotClass3("fillo-form-description", appearance, { slot: "description" }),
2728
- "data-fillo": "description",
2729
- children: schema.description
3109
+ href: `#${inputId}`,
3110
+ onClick: (e) => {
3111
+ e.preventDefault();
3112
+ focusField(inputId);
3113
+ },
3114
+ children: [
3115
+ field.label,
3116
+ ": ",
3117
+ error
3118
+ ]
2730
3119
  }
2731
- )
3120
+ ) }, field.id)) })
2732
3121
  ] }),
2733
- multiPage && api.page.title && api.pageIndex > 0 && /* @__PURE__ */ jsx7("h2", { className: slotClass3("fillo-page-title", appearance, { slot: "pageTitle" }), "data-fillo": "pageTitle", children: api.page.title }),
2734
- /* @__PURE__ */ jsx7("div", { className: slotClass3("fillo-blocks", appearance, { slot: "blocks" }), "data-fillo": "blocks", ref: blocksRef, tabIndex: -1, children: api.blocks.map((block) => /* @__PURE__ */ jsx7(
2735
- BlockRenderer,
3122
+ /* @__PURE__ */ jsx7(
3123
+ "div",
2736
3124
  {
2737
- block,
2738
- api,
2739
- components: props.components,
2740
- customComponents: props.customComponents
2741
- },
2742
- block.id
2743
- )) }),
3125
+ className: slotClass3("fillo-blocks", appearance, { slot: "blocks" }),
3126
+ "data-fillo": "blocks",
3127
+ ref: blocksRef,
3128
+ tabIndex: -1,
3129
+ children: api.blocks.map((block) => /* @__PURE__ */ jsx7(
3130
+ BlockRenderer,
3131
+ {
3132
+ block,
3133
+ api,
3134
+ components: props.components,
3135
+ customComponents: props.customComponents
3136
+ },
3137
+ block.id
3138
+ ))
3139
+ }
3140
+ ),
2744
3141
  !displayOnly && /* @__PURE__ */ jsx7(
2745
3142
  "input",
2746
3143
  {
@@ -2780,43 +3177,66 @@ function ResolvedForm(props) {
2780
3177
  /* @__PURE__ */ jsx7("a", { href: submitFixUrl, target: "_blank", rel: "noopener noreferrer", children: submitFixUrl })
2781
3178
  ] })
2782
3179
  ] }),
2783
- showFooter && /* @__PURE__ */ jsxs6("footer", { className: slotClass3("fillo-footer", appearance, { slot: "footer" }), "data-fillo": "footer", children: [
2784
- multiPage && !api.isFirstPage && /* @__PURE__ */ jsx7(
2785
- "button",
2786
- {
2787
- type: "button",
2788
- className: slotClass3("fillo-button fillo-button--ghost", appearance, { slot: "button", variant: "ghost" }),
2789
- "data-fillo": "button",
2790
- onClick: api.back,
2791
- children: strings.back
2792
- }
2793
- ),
2794
- showPrimaryButton && /* @__PURE__ */ jsx7(
2795
- "button",
2796
- {
2797
- type: "submit",
2798
- className: slotClass3("fillo-button fillo-button--primary", appearance, { slot: "button", variant: "primary" }),
2799
- "data-fillo": "button",
2800
- disabled: api.status === "submitting" || api.uploading || // Wait for the human check before enabling submit (never
2801
- // "Next"). The server rejects a tokenless submit regardless —
2802
- // this is UX only.
2803
- showChallenge && !challengeToken,
2804
- children: api.uploading ? strings.uploading : api.status === "submitting" ? strings.submitting : api.isLastPage ? settings.submitLabel ?? strings.submit : strings.next
2805
- }
2806
- )
2807
- ] }),
3180
+ showFooter && /* @__PURE__ */ jsxs6(
3181
+ "footer",
3182
+ {
3183
+ className: slotClass3("fillo-footer", appearance, { slot: "footer" }),
3184
+ "data-fillo": "footer",
3185
+ children: [
3186
+ multiPage && !api.isFirstPage && /* @__PURE__ */ jsx7(
3187
+ "button",
3188
+ {
3189
+ type: "button",
3190
+ className: slotClass3("fillo-button fillo-button--ghost", appearance, {
3191
+ slot: "button",
3192
+ variant: "ghost"
3193
+ }),
3194
+ "data-fillo": "button",
3195
+ onClick: api.back,
3196
+ children: strings.back
3197
+ }
3198
+ ),
3199
+ showPrimaryButton && /* @__PURE__ */ jsx7(
3200
+ "button",
3201
+ {
3202
+ type: "submit",
3203
+ className: slotClass3("fillo-button fillo-button--primary", appearance, {
3204
+ slot: "button",
3205
+ variant: "primary"
3206
+ }),
3207
+ "data-fillo": "button",
3208
+ disabled: api.status === "submitting" || api.uploading || // Wait for the human check before enabling submit (never
3209
+ // "Next"). The server rejects a tokenless submit regardless —
3210
+ // this is UX only.
3211
+ showChallenge && !challengeToken,
3212
+ children: api.uploading ? strings.uploading : api.status === "submitting" ? strings.submitting : api.isLastPage ? settings.submitLabel ?? strings.submit : strings.next
3213
+ }
3214
+ )
3215
+ ]
3216
+ }
3217
+ ),
2808
3218
  props.poweredBy && /* @__PURE__ */ jsx7(PoweredBy, { label: props.brandLabel, href: props.brandHref })
2809
3219
  ] });
2810
- return /* @__PURE__ */ jsx7(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx7(FilloChromeContext.Provider, { value: chromeValue, children: /* @__PURE__ */ jsx7(FilloStringsContext.Provider, { value: strings, children: /* @__PURE__ */ jsx7(FilloAppearanceContext.Provider, { value: appearance, children: /* @__PURE__ */ jsx7(FilloInstanceIdContext.Provider, { value: instanceId, children: displayOnly ? (
3220
+ return /* @__PURE__ */ jsx7(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx7(FilloChromeContext.Provider, { value: chromeValue, children: /* @__PURE__ */ jsx7(FilloAnnounceContext.Provider, { value: announce, children: /* @__PURE__ */ jsx7(FilloStringsContext.Provider, { value: strings, children: /* @__PURE__ */ jsx7(FilloAppearanceContext.Provider, { value: appearance, children: /* @__PURE__ */ jsx7(FilloInstanceIdContext.Provider, { value: instanceId, children: displayOnly ? (
2811
3221
  // Display-only preview: a plain div (no form element to submit) with
2812
3222
  // every control inside a natively disabled fieldset — unfocusable
2813
3223
  // and unfillable even without the stylesheet or inert support.
2814
- /* @__PURE__ */ jsx7("div", { className: rootClass, "data-fillo": "preview-form", "data-state": "preview", ...themed, children: /* @__PURE__ */ jsx7("fieldset", { disabled: true, className: "fillo-not-open-fields", children: formBody }) })
3224
+ /* @__PURE__ */ jsx7(
3225
+ "div",
3226
+ {
3227
+ className: rootClass,
3228
+ "data-fillo": "preview-form",
3229
+ "data-state": "preview",
3230
+ ...themed,
3231
+ children: /* @__PURE__ */ jsx7("fieldset", { disabled: true, className: "fillo-not-open-fields", children: formBody })
3232
+ }
3233
+ )
2815
3234
  ) : /* @__PURE__ */ jsx7(
2816
3235
  "form",
2817
3236
  {
2818
3237
  className: rootClass,
2819
3238
  "data-fillo": "root",
3239
+ "data-fillo-form-id": props.formId,
2820
3240
  "data-state": api.status,
2821
3241
  "data-page": api.pageIndex + 1,
2822
3242
  "data-last-page": api.isLastPage ? "" : void 0,
@@ -2826,20 +3246,20 @@ function ResolvedForm(props) {
2826
3246
  e.preventDefault();
2827
3247
  const formEl = e.currentTarget;
2828
3248
  if (api.isLastPage) {
2829
- void api.submit().then(() => focusFirstInvalid(formEl)).catch(() => {
3249
+ void api.submit().then(() => focusErrorSummary(formEl)).catch(() => {
2830
3250
  });
2831
3251
  } else {
2832
3252
  api.next();
2833
- focusFirstInvalid(formEl);
3253
+ focusErrorSummary(formEl);
2834
3254
  }
2835
3255
  },
2836
3256
  children: formBody
2837
3257
  }
2838
- ) }) }) }) }) });
3258
+ ) }) }) }) }) }) });
2839
3259
  }
2840
3260
 
2841
3261
  // src/jsx.tsx
2842
- import { useRef as useRef8 } from "react";
3262
+ import { useRef as useRef9 } from "react";
2843
3263
  import {
2844
3264
  codeFormFromJsx,
2845
3265
  FilloJsxError,
@@ -2853,7 +3273,7 @@ function JsxForm(props) {
2853
3273
  const { id, title, description, settings, theme, client, children, ...rest } = props;
2854
3274
  const compiled = codeFormFromJsx({ id, title, description, settings, theme }, children);
2855
3275
  const json = JSON.stringify(compiled.schema) + JSON.stringify(compiled.theme ?? null);
2856
- const memo2 = useRef8(null);
3276
+ const memo2 = useRef9(null);
2857
3277
  if (memo2.current && memo2.current.json !== json && isDevEnv()) {
2858
3278
  console.warn(
2859
3279
  `[fillo] "${id}": form structure changed between renders. If you just edited the form, ignore this. Otherwise: conditional questions are visibleIf={when(\u2026)\u2026}, never {cond && <Fillo.\u2026/>} \u2014 a per-visitor schema churns drafts in your workspace.`
@@ -2862,7 +3282,7 @@ function JsxForm(props) {
2862
3282
  if (!memo2.current || memo2.current.json !== json) {
2863
3283
  memo2.current = { json, form: compiled };
2864
3284
  }
2865
- return /* @__PURE__ */ jsx8(FilloForm, { form: memo2.current.form, client, ...rest });
3285
+ return /* @__PURE__ */ jsx8(FilloForm, { ...{ form: memo2.current.form, client, ...rest } });
2866
3286
  }
2867
3287
  function defineFormFromJsx(element) {
2868
3288
  if (!element || element.type !== JsxForm) {
@@ -2897,6 +3317,7 @@ var Fillo = {
2897
3317
  Date: B.Date,
2898
3318
  FileUpload: B.FileUpload,
2899
3319
  Hidden: B.Hidden,
3320
+ Calculated: B.Calculated,
2900
3321
  Custom: B.Custom,
2901
3322
  Heading: B.Heading,
2902
3323
  Paragraph: B.Paragraph,
@@ -2906,9 +3327,10 @@ var Fillo = {
2906
3327
  };
2907
3328
 
2908
3329
  // src/provider.tsx
2909
- import { useId as useId3, useMemo as useMemo6, useState as useState7 } from "react";
3330
+ import { useEffect as useEffect9, useId as useId3, useMemo as useMemo6, useState as useState7 } from "react";
2910
3331
  import {
2911
3332
  FilloError as FilloError3,
3333
+ createClient as createClient2,
2912
3334
  formSchemasEqual as formSchemasEqual2,
2913
3335
  isFilloError as isFilloError3,
2914
3336
  normalizeFormSchema as normalizeFormSchema2,
@@ -2926,13 +3348,33 @@ function FilloProvider({
2926
3348
  onError,
2927
3349
  renderError,
2928
3350
  preview,
3351
+ renderOnly,
3352
+ skipValidation,
2929
3353
  ...options
2930
3354
  }) {
3355
+ const localPreview = renderOnly === true || skipValidation === true;
3356
+ const activeClient = useMemo6(
3357
+ () => localPreview ? void 0 : client ?? (formId ? createClient2() : void 0),
3358
+ [client, formId, localPreview]
3359
+ );
3360
+ const activeFormId = localPreview ? void 0 : formId;
2931
3361
  const instanceId = useId3().replace(/:/g, "");
2932
3362
  const devEnv = useIsDevEnv();
2933
3363
  const devChrome = preview === true || devEnv;
2934
3364
  if (preview === true) warnPreviewInProduction();
2935
3365
  const codeForm = isCodeForm2(form) ? form : null;
3366
+ const targetError = useMemo6(
3367
+ () => !formId && !localPreview && (!codeForm || !client) ? new FilloError3(
3368
+ "This form has no Fillo target. Pass formId with the schema, use a defineForm() value with client, or set renderOnly for a non-submitting preview.",
3369
+ 422,
3370
+ void 0,
3371
+ "form_target_required"
3372
+ ) : null,
3373
+ [client, codeForm, formId, localPreview]
3374
+ );
3375
+ useEffect9(() => {
3376
+ if (targetError) onError?.(targetError);
3377
+ }, [onError, targetError]);
2936
3378
  const {
2937
3379
  formId: syncedFormId,
2938
3380
  status: syncedStatus,
@@ -2942,10 +3384,11 @@ function FilloProvider({
2942
3384
  accepting: syncedAccepting,
2943
3385
  acceptingReason: syncedAcceptingReason,
2944
3386
  uploadsAvailable,
3387
+ uploadFileSizeLimitMb,
2945
3388
  warningCode,
2946
3389
  warningUrl
2947
- } = useCodeFormSync(form, client, onError, {
2948
- allowUnsynced: Boolean(!client || formId || options.skipValidation)
3390
+ } = useCodeFormSync(form, activeClient, onError, {
3391
+ allowUnsynced: Boolean(!activeClient || activeFormId || localPreview)
2949
3392
  });
2950
3393
  const [resyncWarning, setResyncWarning] = useState7(null);
2951
3394
  const rawSchema = codeForm ? resolvedSchema && !devChrome ? resolvedSchema : codeForm.schema : form;
@@ -2955,13 +3398,14 @@ function FilloProvider({
2955
3398
  }
2956
3399
  const schema = normalized.schema;
2957
3400
  const resolveFormId = useMemo6(
2958
- () => codeForm && client?.key ? async () => {
3401
+ () => codeForm && activeClient?.key ? async () => {
2959
3402
  try {
2960
- const result = await syncCodeForm3(client, codeForm, { bypassCache: true });
3403
+ const result = await syncCodeForm3(activeClient, codeForm, { bypassCache: true });
2961
3404
  setResyncWarning({
2962
3405
  code: result.warningCode,
2963
3406
  url: result.warningUrl,
2964
- uploadsAvailable: result.uploadsAvailable
3407
+ uploadsAvailable: result.uploadsAvailable,
3408
+ uploadFileSizeLimitMb: result.uploadFileSizeLimitMb
2965
3409
  });
2966
3410
  if (result.status === "draft") {
2967
3411
  throw new FilloError3(
@@ -3018,13 +3462,14 @@ function FilloProvider({
3018
3462
  throw failure;
3019
3463
  }
3020
3464
  } : void 0,
3021
- [client, codeForm, onError, schema]
3465
+ [activeClient, codeForm, onError, schema]
3022
3466
  );
3023
3467
  const api = useFilloController({
3024
3468
  ...options,
3025
- client,
3469
+ client: activeClient,
3026
3470
  form: schema,
3027
- formId: syncedFormId ?? formId,
3471
+ formId: syncedFormId ?? activeFormId ?? void 0,
3472
+ skipValidation: localPreview,
3028
3473
  resolveFormId,
3029
3474
  // Dev chrome (preview or dev env) surfaces the developer-grade resolution
3030
3475
  // failure (message + code); an explicit option wins.
@@ -3033,26 +3478,40 @@ function FilloProvider({
3033
3478
  // method; recorded per response so usage stays measurable.
3034
3479
  surface: "headless"
3035
3480
  });
3036
- useDraftFlush(api);
3481
+ useDraftFlush(api, !localPreview);
3037
3482
  const freshWarningCode = resyncWarning ? resyncWarning.code : warningCode;
3038
3483
  const freshWarningUrl = resyncWarning ? resyncWarning.url : warningUrl;
3039
3484
  const freshUploadsAvailable = resyncWarning ? resyncWarning.uploadsAvailable : uploadsAvailable;
3485
+ const freshUploadFileSizeLimitMb = resyncWarning ? resyncWarning.uploadFileSizeLimitMb : uploadFileSizeLimitMb;
3040
3486
  const chromeValue = useMemo6(
3041
3487
  () => ({
3042
3488
  devChrome,
3489
+ renderOnly: localPreview,
3043
3490
  uploadsAvailable: freshUploadsAvailable,
3491
+ uploadFileSizeLimitMb: freshUploadFileSizeLimitMb,
3044
3492
  warningCode: freshWarningCode,
3045
3493
  warningUrl: freshWarningUrl
3046
3494
  }),
3047
- [devChrome, freshUploadsAvailable, freshWarningCode, freshWarningUrl]
3495
+ [
3496
+ devChrome,
3497
+ localPreview,
3498
+ freshUploadsAvailable,
3499
+ freshUploadFileSizeLimitMb,
3500
+ freshWarningCode,
3501
+ freshWarningUrl
3502
+ ]
3048
3503
  );
3049
3504
  const resolvedStrings = resolveStrings2(strings);
3050
- const canonicalSyncError = syncError && !options.skipValidation ? syncError : null;
3505
+ if (targetError) {
3506
+ if (renderError) return /* @__PURE__ */ jsx9(Fragment4, { children: renderError(targetError) });
3507
+ throw targetError;
3508
+ }
3509
+ const canonicalSyncError = syncError && !localPreview ? syncError : null;
3051
3510
  if (canonicalSyncError && renderError) return /* @__PURE__ */ jsx9(Fragment4, { children: renderError(canonicalSyncError) });
3052
3511
  if (canonicalSyncError && !devChrome) return null;
3053
- if (codeFormSyncing && !options.skipValidation && !devChrome) return null;
3054
- const notPublished = Boolean(codeForm) && syncedStatus === "draft" && !options.skipValidation;
3055
- const notAccepting = Boolean(codeForm) && syncedStatus === "published" && syncedAccepting === false && !options.skipValidation;
3512
+ if (codeFormSyncing && !localPreview && !devChrome) return null;
3513
+ const notPublished = Boolean(codeForm) && syncedStatus === "draft" && !localPreview;
3514
+ const notAccepting = Boolean(codeForm) && syncedStatus === "published" && syncedAccepting === false && !localPreview;
3056
3515
  if ((notPublished || notAccepting) && !devChrome) {
3057
3516
  const error = notPublished ? new FilloError3("This form isn't published yet.", 403, void 0, "form_not_published") : new FilloError3(
3058
3517
  "This form is no longer accepting responses.",
@@ -3067,7 +3526,7 @@ function FilloProvider({
3067
3526
 
3068
3527
  // src/index.ts
3069
3528
  import {
3070
- createClient as createClient2,
3529
+ createClient as createClient3,
3071
3530
  provisionWorkspace,
3072
3531
  when,
3073
3532
  FilloJsxError as FilloJsxError2,
@@ -3094,7 +3553,7 @@ export {
3094
3553
  FilloJsxError2 as FilloJsxError,
3095
3554
  FilloProvider,
3096
3555
  FormField,
3097
- createClient2 as createClient,
3556
+ createClient3 as createClient,
3098
3557
  defineForm,
3099
3558
  provisionWorkspace,
3100
3559
  useField,