@usefillo/react 0.10.0 → 0.11.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,25 @@ 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
+ parseGroupedNumber,
487
+ pipeBlock,
488
+ radioGroupStep,
489
+ REQUIRED_FIELD_MESSAGE,
490
+ shouldAutoSubmit,
491
+ slotClass as slotClass2,
492
+ visibleFields
493
+ } from "@usefillo/core";
494
+ import { memo, useEffect as useEffect6, useLayoutEffect as useLayoutEffect2, useMemo as useMemo4, useRef as useRef6, useState as useState5 } from "react";
472
495
 
473
496
  // src/phone.tsx
474
497
  import {
475
498
  PHONE_COUNTRIES,
499
+ PHONE_PICKER_COUNTRIES,
476
500
  countryByIso,
477
501
  countryByTimeZone,
478
502
  digitsOnly,
@@ -524,8 +548,9 @@ function PhoneField({ field, value, error, setValue, ids: providedIds }) {
524
548
  const guess = countryByTimeZone(browserTimeZone()) ?? countryByIso(localeCountry());
525
549
  if (guess) setCountry(guess);
526
550
  }, []);
551
+ const [pendingRaw, setPendingRaw] = useState2(null);
527
552
  const national = nationalFor(stored, country);
528
- const display = formatNational(country, national);
553
+ const display = pendingRaw ?? formatNational(country, national);
529
554
  const inputRef = useRef3(null);
530
555
  const caretDigits = useRef3(null);
531
556
  useLayoutEffect(() => {
@@ -552,15 +577,23 @@ function PhoneField({ field, value, error, setValue, ids: providedIds }) {
552
577
  const caret = el.selectionStart ?? raw.length;
553
578
  caretDigits.current = digitsOnly(raw.slice(0, caret)).length;
554
579
  if (raw.trimStart().startsWith("+")) {
555
- const p = parsePhone(raw);
580
+ const p = parsePhone(raw, country, { previousDigits: national });
581
+ if (p.pending) {
582
+ setPendingRaw(p.raw ?? raw);
583
+ setValue(p.raw ?? raw);
584
+ return;
585
+ }
586
+ setPendingRaw(null);
556
587
  if (p.country) setCountry(p.country);
557
588
  commit(p.country ?? country, p.country ? p.national : digitsOnly(raw));
558
589
  return;
559
590
  }
591
+ setPendingRaw(null);
560
592
  commit(country, digitsOnly(raw));
561
593
  }
562
594
  function pickCountry(c) {
563
595
  touched.current = true;
596
+ setPendingRaw(null);
564
597
  setCountry(c);
565
598
  commit(c, national);
566
599
  inputRef.current?.focus();
@@ -593,7 +626,7 @@ function PhoneField({ field, value, error, setValue, ids: providedIds }) {
593
626
  }
594
627
  )
595
628
  ] }),
596
- error && /* @__PURE__ */ jsx2("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, role: "alert", children: error })
629
+ error && /* @__PURE__ */ jsx2("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, children: error })
597
630
  ] });
598
631
  }
599
632
  function CountrySelect({
@@ -601,6 +634,8 @@ function CountrySelect({
601
634
  onPick,
602
635
  labelledBy
603
636
  }) {
637
+ const strings = useStrings();
638
+ const announce = useFilloAnnounce();
604
639
  const [open, setOpen] = useState2(false);
605
640
  const [query, setQuery] = useState2("");
606
641
  const [active, setActive] = useState2(0);
@@ -609,19 +644,22 @@ function CountrySelect({
609
644
  const triggerRef = useRef3(null);
610
645
  const popoverRef = useRef3(null);
611
646
  const searchRef = useRef3(null);
647
+ const seedRef = useRef3(null);
612
648
  const listId = `${labelledBy}-countries`;
613
649
  const matches = useMemo3(() => {
614
650
  const q = query.trim().toLowerCase();
615
- if (!q) return PHONE_COUNTRIES;
651
+ if (!q) return PHONE_PICKER_COUNTRIES;
616
652
  const qDigits = q.replace(/[^\d]/g, "");
617
- return PHONE_COUNTRIES.filter(
653
+ return PHONE_PICKER_COUNTRIES.filter(
618
654
  (c) => c.name.toLowerCase().includes(q) || c.iso2.toLowerCase() === q || qDigits.length > 0 && c.dialCode.startsWith(qDigits)
619
655
  );
620
656
  }, [query]);
621
657
  useEffect3(() => {
622
658
  if (!open) return;
623
- setQuery("");
624
- setActive(Math.max(0, PHONE_COUNTRIES.indexOf(country)));
659
+ const seed = seedRef.current ?? "";
660
+ seedRef.current = null;
661
+ setQuery(seed);
662
+ setActive(seed ? 0 : Math.max(0, PHONE_PICKER_COUNTRIES.indexOf(country)));
625
663
  const id = requestAnimationFrame(() => searchRef.current?.focus());
626
664
  return () => cancelAnimationFrame(id);
627
665
  }, [open, country]);
@@ -648,8 +686,15 @@ function CountrySelect({
648
686
  if (!open) return;
649
687
  document.getElementById(`${listId}-opt-${active}`)?.scrollIntoView({ block: "nearest" });
650
688
  }, [active, open, listId]);
689
+ const resultsAnnouncement = strings.phoneResultsCount(matches.length);
690
+ useEffect3(() => {
691
+ if (!open || !query.trim()) return;
692
+ const id = setTimeout(() => announce(resultsAnnouncement), 300);
693
+ return () => clearTimeout(id);
694
+ }, [open, query, resultsAnnouncement, announce]);
651
695
  function choose(c) {
652
696
  if (!c) return;
697
+ announce(strings.phoneCountrySelected(c.name));
653
698
  onPick(c);
654
699
  setOpen(false);
655
700
  }
@@ -669,7 +714,30 @@ function CountrySelect({
669
714
  triggerRef.current?.focus();
670
715
  }
671
716
  }
672
- return /* @__PURE__ */ jsxs2("div", { className: "fillo-phone-country", ref: rootRef, children: [
717
+ function onTriggerKeyDown(e) {
718
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
719
+ e.preventDefault();
720
+ setOpen(true);
721
+ return;
722
+ }
723
+ if (e.key.length === 1 && e.key !== " " && !e.ctrlKey && !e.altKey && !e.metaKey) {
724
+ e.preventDefault();
725
+ seedRef.current = e.key;
726
+ setOpen(true);
727
+ }
728
+ }
729
+ function onCompositeBlur(e) {
730
+ if (!open) return;
731
+ const next = e.relatedTarget;
732
+ if (next) {
733
+ if (!rootRef.current?.contains(next)) setOpen(false);
734
+ return;
735
+ }
736
+ queueMicrotask(() => {
737
+ if (!rootRef.current?.contains(document.activeElement)) setOpen(false);
738
+ });
739
+ }
740
+ return /* @__PURE__ */ jsxs2("div", { className: "fillo-phone-country", ref: rootRef, onBlur: onCompositeBlur, children: [
673
741
  /* @__PURE__ */ jsxs2(
674
742
  "button",
675
743
  {
@@ -681,6 +749,7 @@ function CountrySelect({
681
749
  "aria-controls": open ? listId : void 0,
682
750
  "aria-label": `Country: ${country.name} (+${country.dialCode})`,
683
751
  onClick: () => setOpen((o) => !o),
752
+ onKeyDown: onTriggerKeyDown,
684
753
  children: [
685
754
  /* @__PURE__ */ jsx2("span", { className: "fillo-phone-flag-emoji", "aria-hidden": "true", children: flagEmoji(country.iso2) }),
686
755
  /* @__PURE__ */ jsxs2("span", { className: "fillo-phone-dial", children: [
@@ -737,7 +806,7 @@ function CountrySelect({
737
806
  },
738
807
  c.iso2
739
808
  )),
740
- matches.length === 0 && /* @__PURE__ */ jsx2("li", { className: "fillo-phone-empty", children: "No matches" })
809
+ matches.length === 0 && /* @__PURE__ */ jsx2("li", { className: "fillo-phone-empty", role: "option", "aria-disabled": "true", children: "No matches" })
741
810
  ] })
742
811
  ] })
743
812
  ] });
@@ -752,6 +821,7 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
752
821
  const strings = useStrings();
753
822
  const typedInputId = `${ids.inputId}-typed`;
754
823
  const canvasRef = useRef4(null);
824
+ const typedInputRef = useRef4(null);
755
825
  const drawing = useRef4(false);
756
826
  const [hasInk, setHasInk] = useState3(Boolean(value));
757
827
  const [typedName, setTypedName] = useState3("");
@@ -848,6 +918,7 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
848
918
  setHasInk(false);
849
919
  setTypedName("");
850
920
  setValue(null);
921
+ typedInputRef.current?.focus();
851
922
  }
852
923
  const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ") || void 0;
853
924
  return /* @__PURE__ */ jsxs3("div", { ...slots.wrapperProps(`fillo-field fillo-field--signature${error ? " fillo-field--error" : ""}`), children: [
@@ -869,7 +940,8 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
869
940
  {
870
941
  ref: canvasRef,
871
942
  className: "fillo-signature-canvas",
872
- "aria-hidden": "true",
943
+ role: "img",
944
+ "aria-label": hasInk ? strings.signatureSigned : strings.signatureEmpty,
873
945
  onPointerDown: start,
874
946
  onPointerMove: move,
875
947
  onPointerUp: end,
@@ -886,6 +958,7 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
886
958
  "input",
887
959
  {
888
960
  id: typedInputId,
961
+ ref: typedInputRef,
889
962
  type: "text",
890
963
  className: "fillo-input fillo-signature-type-input",
891
964
  autoComplete: "name",
@@ -904,7 +977,7 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
904
977
  ]
905
978
  }
906
979
  ),
907
- error && /* @__PURE__ */ jsx3("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, role: "alert", children: error })
980
+ error && /* @__PURE__ */ jsx3("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, children: error })
908
981
  ] });
909
982
  }
910
983
 
@@ -926,6 +999,14 @@ function formatBytes(bytes) {
926
999
  if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
927
1000
  return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
928
1001
  }
1002
+ function uploadFailureMessage(error, strings) {
1003
+ if (error instanceof Error && error.name === "FilloError") {
1004
+ const status = error.status;
1005
+ if (status !== void 0 && status >= 500) return strings.uploadUnavailable;
1006
+ return error.message || strings.uploadFailed;
1007
+ }
1008
+ return strings.uploadFailed;
1009
+ }
929
1010
  function FileUploadField({ field, value, error, setValue, api, ids: providedIds }) {
930
1011
  const ids = useFilloFieldIds(field.id, providedIds);
931
1012
  const slots = useFieldSlots(field, error);
@@ -952,7 +1033,8 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
952
1033
  const filesRef = useRef5(files);
953
1034
  filesRef.current = files;
954
1035
  const maxFiles = schema.maxFiles ?? 1;
955
- const maxMb = schema.maxFileSizeMb ?? 500;
1036
+ const configuredMaxMb = schema.maxFileSizeMb ?? 500;
1037
+ const maxMb = chrome?.uploadFileSizeLimitMb ? Math.min(configuredMaxMb, chrome.uploadFileSizeLimitMb) : configuredMaxMb;
956
1038
  const maxBytes = maxMb * 1024 * 1024;
957
1039
  const storageBlocked = chrome?.uploadsAvailable === false;
958
1040
  const storageUrl = storageBlocked && chrome?.devChrome ? safeHttpUrl(chrome.warningUrl) : null;
@@ -991,7 +1073,7 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
991
1073
  setValue(next);
992
1074
  } catch (err) {
993
1075
  if (mountedRef.current) {
994
- const message = err instanceof Error && err.name === "FilloError" && err.message ? err.message : strings.uploadFailed;
1076
+ const message = uploadFailureMessage(err, strings);
995
1077
  setInFlight(
996
1078
  (prev) => prev.map((f) => f.key === key ? { ...f, error: message } : f)
997
1079
  );
@@ -1095,6 +1177,7 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
1095
1177
  files.map((f) => /* @__PURE__ */ jsxs4("li", { className: "fillo-file fillo-file--done", children: [
1096
1178
  /* @__PURE__ */ jsx4("span", { className: "fillo-file-name", children: f.name }),
1097
1179
  /* @__PURE__ */ jsx4("span", { className: "fillo-file-meta", children: formatBytes(f.size) }),
1180
+ /* @__PURE__ */ jsx4("span", { className: "fillo-sr-only", children: "Uploaded" }),
1098
1181
  /* @__PURE__ */ jsx4(
1099
1182
  "button",
1100
1183
  {
@@ -1120,32 +1203,34 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
1120
1203
  children: [
1121
1204
  /* @__PURE__ */ jsx4("span", { className: "fillo-file-name", children: f.name }),
1122
1205
  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
- )
1206
+ /* @__PURE__ */ jsxs4("span", { className: "fillo-file-actions", children: [
1207
+ f.file && !f.tooLarge && remaining > 0 && /* @__PURE__ */ jsx4(
1208
+ "button",
1209
+ {
1210
+ type: "button",
1211
+ className: "fillo-file-retry",
1212
+ "aria-label": `${strings.uploadRetry} ${f.name}`,
1213
+ onClick: () => {
1214
+ const retryFile = f.file;
1215
+ if (!retryFile) return;
1216
+ setInFlight((prev) => prev.filter((x) => x.key !== f.key));
1217
+ void startUpload(retryFile);
1218
+ },
1219
+ children: strings.uploadRetry
1220
+ }
1221
+ ),
1222
+ /* @__PURE__ */ jsx4(
1223
+ "button",
1224
+ {
1225
+ type: "button",
1226
+ className: "fillo-file-remove",
1227
+ "aria-label": "Dismiss",
1228
+ onClick: () => setInFlight((prev) => prev.filter((x) => x.key !== f.key)),
1229
+ children: "\xD7"
1230
+ }
1231
+ )
1232
+ ] }),
1233
+ /* @__PURE__ */ jsx4("span", { className: "fillo-file-error", role: "alert", children: f.error })
1149
1234
  ] }) : /* @__PURE__ */ jsxs4(Fragment2, { children: [
1150
1235
  /* @__PURE__ */ jsxs4("span", { className: "fillo-file-meta", children: [
1151
1236
  Math.round(f.fraction * 100),
@@ -1169,7 +1254,7 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
1169
1254
  f.key
1170
1255
  ))
1171
1256
  ] }),
1172
- error && /* @__PURE__ */ jsx4("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, role: "alert", children: error })
1257
+ error && /* @__PURE__ */ jsx4("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, children: error })
1173
1258
  ] });
1174
1259
  }
1175
1260
 
@@ -1198,9 +1283,6 @@ function useControl(base, field, error) {
1198
1283
  "data-fillo": "control"
1199
1284
  };
1200
1285
  }
1201
- function isNativeControlTarget(target) {
1202
- return target instanceof HTMLElement && Boolean(target.closest("input, textarea, select, button"));
1203
- }
1204
1286
  function FieldShell({
1205
1287
  field,
1206
1288
  error,
@@ -1237,7 +1319,7 @@ function FieldShell({
1237
1319
  ] }),
1238
1320
  field.description && /* @__PURE__ */ jsx5("p", { className: descriptionCls, "data-fillo": "fieldDescription", id: ids.descriptionId, children: field.description }),
1239
1321
  children,
1240
- error && /* @__PURE__ */ jsx5("p", { className: errorCls, "data-fillo": "error", id: ids.errorId, role: "alert", children: error })
1322
+ error && /* @__PURE__ */ jsx5("p", { className: errorCls, "data-fillo": "error", id: ids.errorId, children: error })
1241
1323
  ]
1242
1324
  }
1243
1325
  );
@@ -1245,6 +1327,7 @@ function FieldShell({
1245
1327
  function TextInput({ field, value, error, setValue, ids: providedIds }) {
1246
1328
  const ids = useFilloFieldIds(field.id, providedIds);
1247
1329
  const type = field.kind === "email" ? "email" : field.kind === "url" ? "url" : field.kind === "number" ? "number" : field.kind === "date" ? "date" : "text";
1330
+ const autoComplete = field.kind === "email" ? "email" : field.kind === "url" ? "url" : void 0;
1248
1331
  const control = useControl("fillo-input", field, error);
1249
1332
  return /* @__PURE__ */ jsx5(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx5(
1250
1333
  "input",
@@ -1252,6 +1335,7 @@ function TextInput({ field, value, error, setValue, ids: providedIds }) {
1252
1335
  id: ids.inputId,
1253
1336
  ...control,
1254
1337
  type,
1338
+ autoComplete,
1255
1339
  value: typeof value === "string" || typeof value === "number" ? String(value) : "",
1256
1340
  placeholder: field.placeholder,
1257
1341
  maxLength: "maxLength" in field ? field.maxLength : void 0,
@@ -1262,6 +1346,71 @@ function TextInput({ field, value, error, setValue, ids: providedIds }) {
1262
1346
  }
1263
1347
  ) });
1264
1348
  }
1349
+ function NumberInput({ field, value, error, setValue, api, ids: providedIds }) {
1350
+ const ids = useFilloFieldIds(field.id, providedIds);
1351
+ const control = useControl("fillo-input", field, error);
1352
+ const [draft, setDraft] = useState5(null);
1353
+ const inputRef = useRef6(null);
1354
+ const pendingCaret = useRef6(null);
1355
+ const [, setRevertTick] = useState5(0);
1356
+ useLayoutEffect2(() => {
1357
+ if (pendingCaret.current === null) return;
1358
+ const pos = pendingCaret.current;
1359
+ pendingCaret.current = null;
1360
+ inputRef.current?.setSelectionRange(pos, pos);
1361
+ });
1362
+ const num = field;
1363
+ const grouped = num.notation === "grouped";
1364
+ const hasAffix = Boolean(num.prefix) || Boolean(num.suffix);
1365
+ if (!grouped && !hasAffix) {
1366
+ return /* @__PURE__ */ jsx5(
1367
+ TextInput,
1368
+ {
1369
+ field,
1370
+ value,
1371
+ error,
1372
+ setValue,
1373
+ api,
1374
+ ids: providedIds
1375
+ }
1376
+ );
1377
+ }
1378
+ const stored = typeof value === "string" || typeof value === "number" ? String(value) : "";
1379
+ const n = Number(stored);
1380
+ const display = draft !== null ? draft : grouped && stored !== "" && Number.isFinite(n) ? formatGroupedNumber(n, { decimals: num.decimals }) : stored;
1381
+ const input = /* @__PURE__ */ jsx5(
1382
+ "input",
1383
+ {
1384
+ ref: inputRef,
1385
+ id: ids.inputId,
1386
+ ...control,
1387
+ type: "text",
1388
+ inputMode: "decimal",
1389
+ value: display,
1390
+ placeholder: field.placeholder,
1391
+ onBlur: grouped ? () => setDraft(null) : void 0,
1392
+ onChange: (e) => {
1393
+ if (!isValidPartialNumberText(e.target.value)) {
1394
+ pendingCaret.current = e.target.selectionStart ?? e.target.value.length;
1395
+ setRevertTick((t) => t + 1);
1396
+ return;
1397
+ }
1398
+ if (grouped) {
1399
+ setDraft(e.target.value);
1400
+ setValue(parseGroupedNumber(e.target.value));
1401
+ } else {
1402
+ setValue(e.target.value);
1403
+ }
1404
+ },
1405
+ ...fieldAria(field, error, ids)
1406
+ }
1407
+ );
1408
+ return /* @__PURE__ */ jsx5(FieldShell, { field, error, ids, children: hasAffix ? /* @__PURE__ */ jsxs5("div", { className: "fillo-number", children: [
1409
+ num.prefix && /* @__PURE__ */ jsx5("span", { className: "fillo-number-prefix", children: num.prefix }),
1410
+ input,
1411
+ num.suffix && /* @__PURE__ */ jsx5("span", { className: "fillo-number-suffix", children: num.suffix })
1412
+ ] }) : input });
1413
+ }
1265
1414
  function LongText({ field, value, error, setValue, ids: providedIds }) {
1266
1415
  const ids = useFilloFieldIds(field.id, providedIds);
1267
1416
  const control = useControl("fillo-input fillo-textarea", field, error);
@@ -1347,12 +1496,6 @@ function SingleChoice({ field, value, error, setValue, ids: providedIds }) {
1347
1496
  opt.id,
1348
1497
  value === opt.id
1349
1498
  ),
1350
- onClick: (event) => {
1351
- if (isNativeControlTarget(event.target)) return;
1352
- event.preventDefault();
1353
- setOtherOn(false);
1354
- setValue(opt.id);
1355
- },
1356
1499
  children: [
1357
1500
  /* @__PURE__ */ jsx5(
1358
1501
  "input",
@@ -1368,15 +1511,21 @@ function SingleChoice({ field, value, error, setValue, ids: providedIds }) {
1368
1511
  }
1369
1512
  ),
1370
1513
  /* @__PURE__ */ jsx5(ChoiceIcon, { icon: opt.icon }),
1514
+ opt.icon && value === opt.id && // Icon-mode hides the native input, so its check state is
1515
+ // otherwise color-only (forfeits forced-colors' own indicator).
1516
+ /* @__PURE__ */ jsx5("span", { className: "fillo-option-check", "aria-hidden": "true", children: "\u2713" }),
1371
1517
  /* @__PURE__ */ jsx5("span", { className: slotClass2("fillo-option-label", appearance, { slot: "optionLabel", kind: field.kind, fieldId: field.id }), "data-fillo": "optionLabel", children: opt.label })
1372
1518
  ]
1373
1519
  },
1374
1520
  opt.id
1375
1521
  )),
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>.
1522
+ choice.allowOther && // The outer element is a <label> so the whole row is a pointer
1523
+ // target (native label click-forwarding), same as every other
1524
+ // option row above. The free-text input below is its own labelable
1525
+ // descendant, so it handles its own clicks without re-toggling the
1526
+ // radio (dom's proven pattern).
1378
1527
  /* @__PURE__ */ jsxs5(
1379
- "div",
1528
+ "label",
1380
1529
  {
1381
1530
  "data-fillo": "option",
1382
1531
  "data-selected": otherActive ? "" : void 0,
@@ -1386,7 +1535,7 @@ function SingleChoice({ field, value, error, setValue, ids: providedIds }) {
1386
1535
  otherActive
1387
1536
  ),
1388
1537
  children: [
1389
- /* @__PURE__ */ jsxs5("label", { className: "fillo-option-main", children: [
1538
+ /* @__PURE__ */ jsxs5("span", { className: "fillo-option-main", children: [
1390
1539
  /* @__PURE__ */ jsx5(
1391
1540
  "input",
1392
1541
  {
@@ -1454,11 +1603,6 @@ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
1454
1603
  opt.id,
1455
1604
  selected.includes(opt.id)
1456
1605
  ),
1457
- onClick: (event) => {
1458
- if (isNativeControlTarget(event.target)) return;
1459
- event.preventDefault();
1460
- toggle(opt.id);
1461
- },
1462
1606
  children: [
1463
1607
  /* @__PURE__ */ jsx5(
1464
1608
  "input",
@@ -1470,15 +1614,21 @@ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
1470
1614
  }
1471
1615
  ),
1472
1616
  /* @__PURE__ */ jsx5(ChoiceIcon, { icon: opt.icon }),
1617
+ opt.icon && selected.includes(opt.id) && // Icon-mode hides the native input, so its check state is
1618
+ // otherwise color-only (forfeits forced-colors' own indicator).
1619
+ /* @__PURE__ */ jsx5("span", { className: "fillo-option-check", "aria-hidden": "true", children: "\u2713" }),
1473
1620
  /* @__PURE__ */ jsx5("span", { className: slotClass2("fillo-option-label", appearance, { slot: "optionLabel", kind: field.kind, fieldId: field.id }), "data-fillo": "optionLabel", children: opt.label })
1474
1621
  ]
1475
1622
  },
1476
1623
  opt.id
1477
1624
  )),
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>.
1625
+ choice.allowOther && // The outer element is a <label> so the whole row is a pointer
1626
+ // target (native label click-forwarding), same as every other
1627
+ // option row above. The free-text input below is its own labelable
1628
+ // descendant, so it handles its own clicks without re-toggling the
1629
+ // checkbox (dom's proven pattern).
1480
1630
  /* @__PURE__ */ jsxs5(
1481
- "div",
1631
+ "label",
1482
1632
  {
1483
1633
  "data-fillo": "option",
1484
1634
  "data-selected": otherActive ? "" : void 0,
@@ -1488,7 +1638,7 @@ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
1488
1638
  otherActive
1489
1639
  ),
1490
1640
  children: [
1491
- /* @__PURE__ */ jsxs5("label", { className: "fillo-option-main", children: [
1641
+ /* @__PURE__ */ jsxs5("span", { className: "fillo-option-main", children: [
1492
1642
  /* @__PURE__ */ jsx5(
1493
1643
  "input",
1494
1644
  {
@@ -1610,18 +1760,16 @@ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
1610
1760
  {
1611
1761
  id: ids.inputId,
1612
1762
  type: "checkbox",
1613
- role: "switch",
1614
1763
  className: "fillo-toggle-input",
1615
1764
  checked: value === true,
1616
1765
  onChange: (e) => setValue(e.target.checked),
1617
- "aria-checked": value === true,
1618
1766
  ...fieldAria(field, error, ids)
1619
1767
  }
1620
1768
  ),
1621
1769
  /* @__PURE__ */ jsx5("span", { className: "fillo-toggle-track", "aria-hidden": "true", children: /* @__PURE__ */ jsx5("span", { className: "fillo-toggle-thumb" }) })
1622
1770
  ] }),
1623
1771
  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 })
1772
+ error && /* @__PURE__ */ jsx5("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, children: error })
1625
1773
  ]
1626
1774
  }
1627
1775
  );
@@ -1651,28 +1799,21 @@ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
1651
1799
  ] })
1652
1800
  ] }),
1653
1801
  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 })
1802
+ error && /* @__PURE__ */ jsx5("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, children: error })
1655
1803
  ]
1656
1804
  }
1657
1805
  );
1658
1806
  }
1659
1807
  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;
1808
+ const group = e.currentTarget;
1809
+ const rtl = getComputedStyle(group).direction === "rtl";
1810
+ const index = rovingIndex(steps, current);
1811
+ const next = radioGroupStep(e.key, index, steps.length, { rtl });
1812
+ if (next == null) return;
1663
1813
  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
1814
  const value = steps[next];
1673
1815
  if (value === void 0) return;
1674
1816
  setValue(value);
1675
- const group = e.currentTarget;
1676
1817
  const buttons = group.querySelectorAll('[role="radio"]');
1677
1818
  buttons[next]?.focus();
1678
1819
  }
@@ -1693,6 +1834,7 @@ function Rating({ field, value, error, setValue, ids: providedIds }) {
1693
1834
  return /* @__PURE__ */ jsx5(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx5(
1694
1835
  "div",
1695
1836
  {
1837
+ id: ids.inputId,
1696
1838
  ...control,
1697
1839
  role: "radiogroup",
1698
1840
  "aria-labelledby": ids.labelId,
@@ -1714,8 +1856,11 @@ function Rating({ field, value, error, setValue, ids: providedIds }) {
1714
1856
  "aria-label": `${n} of ${max}`,
1715
1857
  "aria-checked": n === current,
1716
1858
  tabIndex: i === tabbable ? 0 : -1,
1717
- onClick: () => setValue(n === current ? null : n),
1718
- children: "\u2605"
1859
+ onClick: (event) => {
1860
+ if (event.detail === 0 && n === current) return;
1861
+ setValue(n === current ? null : n);
1862
+ },
1863
+ children: n <= current ? "\u2605" : "\u2606"
1719
1864
  },
1720
1865
  n
1721
1866
  ))
@@ -1745,6 +1890,7 @@ function LinearScale({ field, value, error, setValue, ids: providedIds }) {
1745
1890
  /* @__PURE__ */ jsx5(
1746
1891
  "div",
1747
1892
  {
1893
+ id: ids.inputId,
1748
1894
  ...control,
1749
1895
  role: "radiogroup",
1750
1896
  "aria-labelledby": ids.labelId,
@@ -1768,7 +1914,10 @@ function LinearScale({ field, value, error, setValue, ids: providedIds }) {
1768
1914
  "aria-label": String(n),
1769
1915
  "aria-checked": value === n,
1770
1916
  tabIndex: i === tabbable ? 0 : -1,
1771
- onClick: () => setValue(value === n ? null : n),
1917
+ onClick: (event) => {
1918
+ if (event.detail === 0 && n === current) return;
1919
+ setValue(value === n ? null : n);
1920
+ },
1772
1921
  children: n
1773
1922
  },
1774
1923
  n
@@ -1783,19 +1932,34 @@ function LinearScale({ field, value, error, setValue, ids: providedIds }) {
1783
1932
  }
1784
1933
  function Ranking({ field, value, error, setValue, ids: providedIds }) {
1785
1934
  const ids = useFilloFieldIds(field.id, providedIds);
1935
+ const strings = useStrings();
1936
+ const announce = useFilloAnnounce();
1786
1937
  const ranking = field;
1787
1938
  const answered = Array.isArray(value) ? value : [];
1788
1939
  const order = [
1789
1940
  ...answered.filter((id) => ranking.options.some((o) => o.id === id)),
1790
1941
  ...ranking.options.map((o) => o.id).filter((id) => !answered.includes(id))
1791
1942
  ];
1792
- function move(id, delta) {
1943
+ const pressedRef = useRef6(null);
1944
+ useEffect6(() => {
1945
+ const pressed = pressedRef.current;
1946
+ pressedRef.current = null;
1947
+ if (!pressed || !pressed.disabled) return;
1948
+ const sibling = pressed.parentElement?.querySelector(
1949
+ ".fillo-ranking-move:not(:disabled)"
1950
+ );
1951
+ sibling?.focus();
1952
+ }, [value]);
1953
+ function move(id, delta, button) {
1793
1954
  const index = order.indexOf(id);
1794
1955
  const target = index + delta;
1795
1956
  if (target < 0 || target >= order.length) return;
1957
+ pressedRef.current = button;
1796
1958
  const next = [...order];
1797
1959
  [next[index], next[target]] = [next[target], next[index]];
1798
1960
  setValue(next);
1961
+ const moved = ranking.options.find((o) => o.id === id);
1962
+ if (moved) announce(strings.rankingPosition(moved.label, target + 1, order.length));
1799
1963
  }
1800
1964
  return /* @__PURE__ */ jsx5(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx5(
1801
1965
  "div",
@@ -1818,7 +1982,7 @@ function Ranking({ field, value, error, setValue, ids: providedIds }) {
1818
1982
  className: "fillo-ranking-move",
1819
1983
  "aria-label": `Move ${option.label} up`,
1820
1984
  disabled: index === 0,
1821
- onClick: () => move(id, -1),
1985
+ onClick: (e) => move(id, -1, e.currentTarget),
1822
1986
  children: "\u2191"
1823
1987
  }
1824
1988
  ),
@@ -1829,7 +1993,7 @@ function Ranking({ field, value, error, setValue, ids: providedIds }) {
1829
1993
  className: "fillo-ranking-move",
1830
1994
  "aria-label": `Move ${option.label} down`,
1831
1995
  disabled: index === order.length - 1,
1832
- onClick: () => move(id, 1),
1996
+ onClick: (e) => move(id, 1, e.currentTarget),
1833
1997
  children: "\u2193"
1834
1998
  }
1835
1999
  )
@@ -1872,12 +2036,12 @@ function Matrix({ field, value, error, setValue, ids: providedIds }) {
1872
2036
  "div",
1873
2037
  {
1874
2038
  role: "radiogroup",
1875
- "aria-labelledby": rowLabelId,
2039
+ "aria-labelledby": `${ids.labelId} ${rowLabelId}`,
1876
2040
  "aria-owns": matrix.columns.map(radioId).join(" "),
1877
2041
  children: /* @__PURE__ */ jsx5("span", { id: rowLabelId, children: row.label })
1878
2042
  }
1879
2043
  ) }),
1880
- matrix.columns.map((col) => /* @__PURE__ */ jsx5("td", { children: /* @__PURE__ */ jsx5(
2044
+ matrix.columns.map((col) => /* @__PURE__ */ jsx5("td", { "data-label": col.label, children: /* @__PURE__ */ jsx5("label", { className: "fillo-matrix-cell", children: /* @__PURE__ */ jsx5(
1881
2045
  "input",
1882
2046
  {
1883
2047
  id: radioId(col),
@@ -1890,7 +2054,7 @@ function Matrix({ field, value, error, setValue, ids: providedIds }) {
1890
2054
  checked: answers[row.id] === col.id,
1891
2055
  onChange: () => setValue({ ...answers, [row.id]: col.id })
1892
2056
  }
1893
- ) }, col.id))
2057
+ ) }) }, col.id))
1894
2058
  ] }, row.id)
1895
2059
  );
1896
2060
  }) })
@@ -1901,12 +2065,45 @@ function Matrix({ field, value, error, setValue, ids: providedIds }) {
1901
2065
  function Hidden() {
1902
2066
  return null;
1903
2067
  }
2068
+ function Calculated({ field, value, ids: providedIds }) {
2069
+ const ids = useFilloFieldIds(field.id, providedIds);
2070
+ const state = { kind: field.kind, fieldId: field.id, invalid: false, required: false };
2071
+ const rootCls = useSlotClass("fillo-field fillo-field--calculated fillo-calculated", {
2072
+ slot: "calculated",
2073
+ ...state
2074
+ });
2075
+ const labelCls = useSlotClass("fillo-label", { slot: "label", ...state });
2076
+ const descriptionCls = useSlotClass("fillo-description", { slot: "fieldDescription", ...state });
2077
+ const answered = value !== void 0 && value !== null && value !== "";
2078
+ return /* @__PURE__ */ jsxs5(
2079
+ "div",
2080
+ {
2081
+ className: rootCls,
2082
+ "data-fillo": "calculated",
2083
+ "data-field": field.id,
2084
+ "data-kind": field.kind,
2085
+ children: [
2086
+ /* @__PURE__ */ jsx5("label", { className: labelCls, "data-fillo": "label", id: ids.labelId, htmlFor: ids.inputId, children: field.label }),
2087
+ field.description && /* @__PURE__ */ jsx5("p", { className: descriptionCls, "data-fillo": "fieldDescription", id: ids.descriptionId, children: field.description }),
2088
+ /* @__PURE__ */ jsx5(
2089
+ "output",
2090
+ {
2091
+ id: ids.inputId,
2092
+ className: `fillo-calculated-value${answered ? "" : " fillo-calculated-value--empty"}`,
2093
+ "aria-describedby": field.description ? ids.descriptionId : void 0,
2094
+ children: answered ? formatAnswer(field, value) : "\u2014"
2095
+ }
2096
+ )
2097
+ ]
2098
+ }
2099
+ );
2100
+ }
1904
2101
  var DEFAULT_COMPONENTS = {
1905
2102
  short_text: TextInput,
1906
2103
  email: TextInput,
1907
2104
  url: TextInput,
1908
2105
  phone: PhoneField,
1909
- number: TextInput,
2106
+ number: NumberInput,
1910
2107
  date: TextInput,
1911
2108
  long_text: LongText,
1912
2109
  select: SingleChoice,
@@ -1919,7 +2116,8 @@ var DEFAULT_COMPONENTS = {
1919
2116
  matrix: Matrix,
1920
2117
  signature: SignatureField,
1921
2118
  file_upload: FileUploadField,
1922
- hidden: Hidden
2119
+ hidden: Hidden,
2120
+ calculated: Calculated
1923
2121
  };
1924
2122
  function ContentRenderer({ block }) {
1925
2123
  switch (block.kind) {
@@ -2034,7 +2232,7 @@ function FormField({
2034
2232
  }
2035
2233
 
2036
2234
  // src/turnstile.tsx
2037
- import { useEffect as useEffect7, useRef as useRef6 } from "react";
2235
+ import { useEffect as useEffect7, useRef as useRef7 } from "react";
2038
2236
  import { jsx as jsx6 } from "react/jsx-runtime";
2039
2237
  var SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
2040
2238
  var SCRIPT_ID = "fillo-turnstile-script";
@@ -2093,13 +2291,13 @@ function TurnstileWidget({
2093
2291
  registerReset,
2094
2292
  className
2095
2293
  }) {
2096
- const containerRef = useRef6(null);
2097
- const widgetIdRef = useRef6(null);
2098
- const onTokenRef = useRef6(onToken);
2294
+ const containerRef = useRef7(null);
2295
+ const widgetIdRef = useRef7(null);
2296
+ const onTokenRef = useRef7(onToken);
2099
2297
  onTokenRef.current = onToken;
2100
- const onErrorRef = useRef6(onError);
2298
+ const onErrorRef = useRef7(onError);
2101
2299
  onErrorRef.current = onError;
2102
- const registerResetRef = useRef6(registerReset);
2300
+ const registerResetRef = useRef7(registerReset);
2103
2301
  registerResetRef.current = registerReset;
2104
2302
  useEffect7(() => {
2105
2303
  let cancelled = false;
@@ -2155,7 +2353,7 @@ function TurnstileWidget({
2155
2353
  // src/FilloForm.tsx
2156
2354
  import { Fragment as Fragment3, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
2157
2355
  function themeProps(theme) {
2158
- const safeTheme = normalizeFormTheme(theme);
2356
+ const safeTheme = resolveThemeAppearance(normalizeFormTheme(theme));
2159
2357
  if (!safeTheme) return {};
2160
2358
  const style = {};
2161
2359
  if (safeTheme.primary) style["--fillo-primary"] = safeTheme.primary;
@@ -2175,17 +2373,25 @@ function badgeUrl(href) {
2175
2373
  url.searchParams.set("utm_source", "form_badge");
2176
2374
  return url.toString();
2177
2375
  }
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
- };
2376
+ var FOCUSABLE_SELECTOR = 'input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])';
2377
+ function resolveFocusable(el) {
2378
+ if (!el) return null;
2379
+ return el.matches(FOCUSABLE_SELECTOR) ? el : el.querySelector(FOCUSABLE_SELECTOR);
2380
+ }
2381
+ function nextFrame(run) {
2186
2382
  if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
2187
2383
  else setTimeout(run, 0);
2188
2384
  }
2385
+ function focusErrorSummary(formEl) {
2386
+ if (!formEl || typeof window === "undefined") return;
2387
+ nextFrame(() => {
2388
+ formEl.querySelector(".fillo-error-summary")?.focus();
2389
+ });
2390
+ }
2391
+ function focusField(inputId) {
2392
+ if (typeof document === "undefined") return;
2393
+ resolveFocusable(document.getElementById(inputId))?.focus();
2394
+ }
2189
2395
  var HP_STYLE = {
2190
2396
  position: "absolute",
2191
2397
  left: "-9999px",
@@ -2227,6 +2433,7 @@ function FilloForm(props) {
2227
2433
  accepting: syncedAccepting,
2228
2434
  acceptingReason: syncedAcceptingReason,
2229
2435
  uploadsAvailable: syncedUploadsAvailable,
2436
+ uploadFileSizeLimitMb: syncedUploadFileSizeLimitMb,
2230
2437
  resolvedSchema,
2231
2438
  resolvedTheme,
2232
2439
  warningCode: syncedWarningCode,
@@ -2238,7 +2445,7 @@ function FilloForm(props) {
2238
2445
  });
2239
2446
  const renderResolvedSnapshot = Boolean(resolvedSchema && !devChrome);
2240
2447
  const inlineSchema = codeForm ? renderResolvedSnapshot ? resolvedSchema : codeForm.schema : form;
2241
- const onErrorRef = useRef7(onError);
2448
+ const onErrorRef = useRef8(onError);
2242
2449
  useEffect8(() => {
2243
2450
  onErrorRef.current = onError;
2244
2451
  });
@@ -2262,6 +2469,7 @@ function FilloForm(props) {
2262
2469
  accepting: published.accepting,
2263
2470
  acceptingReason: published.acceptingReason,
2264
2471
  uploadsAvailable: published.uploadsAvailable,
2472
+ uploadFileSizeLimitMb: published.uploadFileSizeLimitMb,
2265
2473
  branding: published.branding ?? null,
2266
2474
  challenge: published.challenge ?? null
2267
2475
  });
@@ -2401,6 +2609,7 @@ function FilloForm(props) {
2401
2609
  stagedNotice: codeChangesStaged,
2402
2610
  syncErrorNotice: syncError ?? syncNoticeError ?? void 0,
2403
2611
  uploadsAvailable: codeForm ? syncedUploadsAvailable : currentLoaded?.uploadsAvailable,
2612
+ uploadFileSizeLimitMb: codeForm ? syncedUploadFileSizeLimitMb : currentLoaded?.uploadFileSizeLimitMb ?? props.uploadFileSizeLimitMb,
2404
2613
  syncWarningCode: codeForm ? syncedWarningCode : void 0,
2405
2614
  syncWarningUrl: syncedWarningUrl,
2406
2615
  challenge
@@ -2466,17 +2675,17 @@ function ResolvedForm(props) {
2466
2675
  const instanceId = useId2().replace(/:/g, "");
2467
2676
  const devEnv = useIsDevEnv();
2468
2677
  const devChrome = !displayOnly && (props.preview === true || devEnv);
2469
- const hpRef = useRef7(null);
2678
+ const hpRef = useRef8(null);
2470
2679
  const codeForm = isCodeForm2(props.form) ? props.form : null;
2471
2680
  const client = props.client;
2472
2681
  const challenge = props.challenge;
2473
2682
  const challengeRequired = Boolean(challenge);
2474
- const challengeTokenRef = useRef7(void 0);
2683
+ const challengeTokenRef = useRef8(void 0);
2475
2684
  const [resyncWarning, setResyncWarning] = useState6(null);
2476
2685
  const [resolutionFailureCode, setResolutionFailureCode] = useState6(null);
2477
2686
  const [challengeToken, setChallengeToken] = useState6(void 0);
2478
2687
  const [challengeError, setChallengeError] = useState6(false);
2479
- const resetChallengeRef = useRef7(void 0);
2688
+ const resetChallengeRef = useRef8(void 0);
2480
2689
  const handleChallengeToken = useCallback((token) => {
2481
2690
  challengeTokenRef.current = token;
2482
2691
  setChallengeToken(token);
@@ -2494,7 +2703,8 @@ function ResolvedForm(props) {
2494
2703
  setResyncWarning({
2495
2704
  code: result.warningCode,
2496
2705
  url: result.warningUrl,
2497
- uploadsAvailable: result.uploadsAvailable
2706
+ uploadsAvailable: result.uploadsAvailable,
2707
+ uploadFileSizeLimitMb: result.uploadFileSizeLimitMb
2498
2708
  });
2499
2709
  if (result.status === "draft") {
2500
2710
  throw new FilloError2(
@@ -2581,18 +2791,42 @@ function ResolvedForm(props) {
2581
2791
  }
2582
2792
  });
2583
2793
  useDraftFlush(api, !displayOnly);
2794
+ const strings = resolveStrings(props.strings);
2795
+ const announceRef = useRef8(null);
2796
+ const announce = useCallback((text) => {
2797
+ const el = announceRef.current;
2798
+ if (!el) return;
2799
+ if (el.textContent === text) {
2800
+ el.textContent = "";
2801
+ nextFrame(() => {
2802
+ if (announceRef.current) announceRef.current.textContent = text;
2803
+ });
2804
+ return;
2805
+ }
2806
+ el.textContent = text;
2807
+ }, []);
2808
+ useEffect8(() => {
2809
+ if (api.status === "submitting") announce(strings.submittingAnnouncement);
2810
+ }, [api.status, announce, strings.submittingAnnouncement]);
2811
+ const resuming = api.resumedDraft || api.editingPrevious;
2812
+ const resumeAnnouncement = api.editingPrevious ? strings.editNotice : strings.resumeNotice;
2813
+ useEffect8(() => {
2814
+ if (resuming) announce(resumeAnnouncement);
2815
+ }, [resuming, resumeAnnouncement, announce]);
2584
2816
  const themed = useMemo5(() => themeProps(theme), [theme]);
2585
2817
  const warningCode = resyncWarning ? resyncWarning.code : props.syncWarningCode;
2586
2818
  const warningUrl = resyncWarning ? resyncWarning.url : props.syncWarningUrl;
2587
2819
  const uploadsAvailable = resyncWarning ? resyncWarning.uploadsAvailable : props.uploadsAvailable;
2820
+ const uploadFileSizeLimitMb = resyncWarning ? resyncWarning.uploadFileSizeLimitMb : props.uploadFileSizeLimitMb;
2588
2821
  const chromeValue = useMemo5(
2589
2822
  () => ({
2590
2823
  devChrome,
2591
2824
  uploadsAvailable,
2825
+ uploadFileSizeLimitMb,
2592
2826
  warningCode,
2593
2827
  warningUrl
2594
2828
  }),
2595
- [devChrome, uploadsAvailable, warningCode, warningUrl]
2829
+ [devChrome, uploadsAvailable, uploadFileSizeLimitMb, warningCode, warningUrl]
2596
2830
  );
2597
2831
  const settings = schema.settings;
2598
2832
  const submitted = api.status === "submitted";
@@ -2602,19 +2836,23 @@ function ResolvedForm(props) {
2602
2836
  const url = safeHttpUrl(settings.redirectUrl);
2603
2837
  if (url) window.location.assign(url);
2604
2838
  }, [liveSubmit, settings.redirectUrl]);
2605
- const successRef = useRef7(null);
2839
+ const successRef = useRef8(null);
2606
2840
  useEffect8(() => {
2607
2841
  if (liveSubmit) successRef.current?.focus();
2608
2842
  }, [liveSubmit]);
2609
- const blocksRef = useRef7(null);
2610
- const prevPageIndex = useRef7(api.pageIndex);
2843
+ const blocksRef = useRef8(null);
2844
+ const prevPageIndex = useRef8(api.pageIndex);
2611
2845
  useEffect8(() => {
2612
2846
  if (prevPageIndex.current === api.pageIndex) return;
2613
2847
  prevPageIndex.current = api.pageIndex;
2614
- blocksRef.current?.focus();
2848
+ const container = blocksRef.current?.parentElement;
2849
+ const heading = container?.querySelector(".fillo-page-title, .fillo-title");
2850
+ const target = heading ?? blocksRef.current;
2851
+ if (!target) return;
2852
+ if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");
2853
+ target.focus();
2615
2854
  }, [api.pageIndex]);
2616
2855
  const appearance = props.appearance;
2617
- const strings = resolveStrings(props.strings);
2618
2856
  if (api.status === "submitted") {
2619
2857
  return /* @__PURE__ */ jsxs6(
2620
2858
  "div",
@@ -2638,7 +2876,7 @@ function ResolvedForm(props) {
2638
2876
  className: slotClass3("fillo-success", appearance, { slot: "success", status: "submitted" }),
2639
2877
  "data-fillo": "success",
2640
2878
  children: [
2641
- /* @__PURE__ */ jsx7("div", { className: "fillo-success-mark", "aria-hidden": "true", children: "\u2713" }),
2879
+ /* @__PURE__ */ jsx7("div", { className: "fillo-success-mark", "aria-hidden": "true" }),
2642
2880
  /* @__PURE__ */ jsx7("h2", { className: "fillo-success-title", children: settings.successTitle ?? strings.successTitle }),
2643
2881
  /* @__PURE__ */ jsx7("p", { className: "fillo-success-message", children: api.duplicateSubmission ? strings.alreadyAnswered : settings.successMessage ?? strings.successMessage })
2644
2882
  ]
@@ -2661,11 +2899,32 @@ function ResolvedForm(props) {
2661
2899
  const progressPos = progressSeq.indexOf(api.page.id);
2662
2900
  const progressStep = progressPos >= 0 ? progressPos + 1 : Math.min(api.pageIndex + 1, progressTotal);
2663
2901
  const submitFixUrl = devChrome && resolutionFailureCode === "form_not_published" && warningCode === "storage_required" ? safeHttpUrl(warningUrl) : null;
2902
+ const invalidFields = [];
2903
+ for (const block of api.blocks) {
2904
+ if (!isField2(block)) continue;
2905
+ const raw = api.errors[block.id];
2906
+ if (!raw) continue;
2907
+ invalidFields.push({
2908
+ field: block,
2909
+ error: raw === REQUIRED_FIELD_MESSAGE2 ? strings.required : raw,
2910
+ inputId: createFilloFieldIds(instanceId, block.id).inputId
2911
+ });
2912
+ }
2664
2913
  const rootClass = slotClass3(`fillo-form ${props.className ?? ""}`, appearance, {
2665
2914
  slot: "root",
2666
2915
  status: api.status
2667
2916
  });
2668
2917
  const formBody = /* @__PURE__ */ jsxs6(Fragment3, { children: [
2918
+ /* @__PURE__ */ jsx7(
2919
+ "span",
2920
+ {
2921
+ ref: announceRef,
2922
+ className: "fillo-sr-only",
2923
+ role: "status",
2924
+ "aria-live": "polite",
2925
+ "data-fillo": "announce"
2926
+ }
2927
+ ),
2669
2928
  devChrome && /* @__PURE__ */ jsx7(
2670
2929
  DevChrome,
2671
2930
  {
@@ -2678,12 +2937,11 @@ function ResolvedForm(props) {
2678
2937
  noClient: !props.client && !props.skipValidation
2679
2938
  }
2680
2939
  ),
2681
- (api.resumedDraft || api.editingPrevious) && /* @__PURE__ */ jsxs6(
2940
+ resuming && /* @__PURE__ */ jsxs6(
2682
2941
  "div",
2683
2942
  {
2684
2943
  className: slotClass3("fillo-resume", appearance, { slot: "resume" }),
2685
2944
  "data-fillo": "resume",
2686
- role: "status",
2687
2945
  children: [
2688
2946
  /* @__PURE__ */ jsx7("span", { className: "fillo-resume-text", children: api.editingPrevious ? strings.editNotice : strings.resumeNotice }),
2689
2947
  /* @__PURE__ */ jsx7("button", { type: "button", className: "fillo-resume-clear", onClick: api.resetDraft, children: strings.resumeStartOver })
@@ -2731,6 +2989,24 @@ function ResolvedForm(props) {
2731
2989
  )
2732
2990
  ] }),
2733
2991
  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 }),
2992
+ invalidFields.length > 0 && /* @__PURE__ */ jsxs6("div", { className: "fillo-error-summary", role: "alert", tabIndex: -1, "data-fillo": "error-summary", children: [
2993
+ /* @__PURE__ */ jsx7("p", { className: "fillo-error-summary-title", children: strings.errorSummaryTitle }),
2994
+ /* @__PURE__ */ jsx7("ul", { children: invalidFields.map(({ field, error, inputId }) => /* @__PURE__ */ jsx7("li", { children: /* @__PURE__ */ jsxs6(
2995
+ "a",
2996
+ {
2997
+ href: `#${inputId}`,
2998
+ onClick: (e) => {
2999
+ e.preventDefault();
3000
+ focusField(inputId);
3001
+ },
3002
+ children: [
3003
+ field.label,
3004
+ ": ",
3005
+ error
3006
+ ]
3007
+ }
3008
+ ) }, field.id)) })
3009
+ ] }),
2734
3010
  /* @__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
3011
  BlockRenderer,
2736
3012
  {
@@ -2807,7 +3083,7 @@ function ResolvedForm(props) {
2807
3083
  ] }),
2808
3084
  props.poweredBy && /* @__PURE__ */ jsx7(PoweredBy, { label: props.brandLabel, href: props.brandHref })
2809
3085
  ] });
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 ? (
3086
+ 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
3087
  // Display-only preview: a plain div (no form element to submit) with
2812
3088
  // every control inside a natively disabled fieldset — unfocusable
2813
3089
  // and unfillable even without the stylesheet or inert support.
@@ -2826,20 +3102,20 @@ function ResolvedForm(props) {
2826
3102
  e.preventDefault();
2827
3103
  const formEl = e.currentTarget;
2828
3104
  if (api.isLastPage) {
2829
- void api.submit().then(() => focusFirstInvalid(formEl)).catch(() => {
3105
+ void api.submit().then(() => focusErrorSummary(formEl)).catch(() => {
2830
3106
  });
2831
3107
  } else {
2832
3108
  api.next();
2833
- focusFirstInvalid(formEl);
3109
+ focusErrorSummary(formEl);
2834
3110
  }
2835
3111
  },
2836
3112
  children: formBody
2837
3113
  }
2838
- ) }) }) }) }) });
3114
+ ) }) }) }) }) }) });
2839
3115
  }
2840
3116
 
2841
3117
  // src/jsx.tsx
2842
- import { useRef as useRef8 } from "react";
3118
+ import { useRef as useRef9 } from "react";
2843
3119
  import {
2844
3120
  codeFormFromJsx,
2845
3121
  FilloJsxError,
@@ -2853,7 +3129,7 @@ function JsxForm(props) {
2853
3129
  const { id, title, description, settings, theme, client, children, ...rest } = props;
2854
3130
  const compiled = codeFormFromJsx({ id, title, description, settings, theme }, children);
2855
3131
  const json = JSON.stringify(compiled.schema) + JSON.stringify(compiled.theme ?? null);
2856
- const memo2 = useRef8(null);
3132
+ const memo2 = useRef9(null);
2857
3133
  if (memo2.current && memo2.current.json !== json && isDevEnv()) {
2858
3134
  console.warn(
2859
3135
  `[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.`
@@ -2897,6 +3173,7 @@ var Fillo = {
2897
3173
  Date: B.Date,
2898
3174
  FileUpload: B.FileUpload,
2899
3175
  Hidden: B.Hidden,
3176
+ Calculated: B.Calculated,
2900
3177
  Custom: B.Custom,
2901
3178
  Heading: B.Heading,
2902
3179
  Paragraph: B.Paragraph,
@@ -2942,6 +3219,7 @@ function FilloProvider({
2942
3219
  accepting: syncedAccepting,
2943
3220
  acceptingReason: syncedAcceptingReason,
2944
3221
  uploadsAvailable,
3222
+ uploadFileSizeLimitMb,
2945
3223
  warningCode,
2946
3224
  warningUrl
2947
3225
  } = useCodeFormSync(form, client, onError, {
@@ -2961,7 +3239,8 @@ function FilloProvider({
2961
3239
  setResyncWarning({
2962
3240
  code: result.warningCode,
2963
3241
  url: result.warningUrl,
2964
- uploadsAvailable: result.uploadsAvailable
3242
+ uploadsAvailable: result.uploadsAvailable,
3243
+ uploadFileSizeLimitMb: result.uploadFileSizeLimitMb
2965
3244
  });
2966
3245
  if (result.status === "draft") {
2967
3246
  throw new FilloError3(
@@ -3037,14 +3316,16 @@ function FilloProvider({
3037
3316
  const freshWarningCode = resyncWarning ? resyncWarning.code : warningCode;
3038
3317
  const freshWarningUrl = resyncWarning ? resyncWarning.url : warningUrl;
3039
3318
  const freshUploadsAvailable = resyncWarning ? resyncWarning.uploadsAvailable : uploadsAvailable;
3319
+ const freshUploadFileSizeLimitMb = resyncWarning ? resyncWarning.uploadFileSizeLimitMb : uploadFileSizeLimitMb;
3040
3320
  const chromeValue = useMemo6(
3041
3321
  () => ({
3042
3322
  devChrome,
3043
3323
  uploadsAvailable: freshUploadsAvailable,
3324
+ uploadFileSizeLimitMb: freshUploadFileSizeLimitMb,
3044
3325
  warningCode: freshWarningCode,
3045
3326
  warningUrl: freshWarningUrl
3046
3327
  }),
3047
- [devChrome, freshUploadsAvailable, freshWarningCode, freshWarningUrl]
3328
+ [devChrome, freshUploadsAvailable, freshUploadFileSizeLimitMb, freshWarningCode, freshWarningUrl]
3048
3329
  );
3049
3330
  const resolvedStrings = resolveStrings2(strings);
3050
3331
  const canonicalSyncError = syncError && !options.skipValidation ? syncError : null;