@usefillo/react 0.7.0 → 0.9.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
@@ -1,20 +1,24 @@
1
1
  "use client";
2
2
 
3
3
  // src/FilloForm.tsx
4
- import { useEffect as useEffect7, useId as useId2, useMemo as useMemo5, useRef as useRef5, useState as useState6 } from "react";
4
+ import { useCallback, useEffect as useEffect8, useId as useId2, useMemo as useMemo5, useRef as useRef7, useState as useState6 } from "react";
5
5
  import {
6
6
  FilloError as FilloError2,
7
7
  createClient,
8
+ formSchemasEqual,
9
+ isFilloError as isFilloError2,
8
10
  needsExplicitSubmit,
9
11
  normalizeFormSchema,
10
12
  normalizeFormTheme,
11
- visibleFields as visibleFields2
13
+ reachableFields,
14
+ reachablePageSequence
12
15
  } from "@usefillo/core";
13
16
  import { resolveStrings, slotClass as slotClass3 } from "@usefillo/core";
14
17
 
15
18
  // src/appearance.tsx
16
19
  import { createContext, useContext } from "react";
17
20
  import {
21
+ DEFAULT_FIELD_STRINGS,
18
22
  DEFAULT_STRINGS,
19
23
  slotClass
20
24
  } from "@usefillo/core";
@@ -49,7 +53,10 @@ function useFieldSlots(field, error) {
49
53
  control: (base) => slotClass(base, appearance, { slot: "control", ...state })
50
54
  };
51
55
  }
52
- var FilloStringsContext = createContext(DEFAULT_STRINGS);
56
+ var FilloStringsContext = createContext({
57
+ ...DEFAULT_STRINGS,
58
+ ...DEFAULT_FIELD_STRINGS
59
+ });
53
60
  function useStrings() {
54
61
  return useContext(FilloStringsContext);
55
62
  }
@@ -121,7 +128,12 @@ function useFilloController(options) {
121
128
  surface: options.surface ?? "headless",
122
129
  // Wrapped so the engine (created once) always calls the latest resolver.
123
130
  resolveFormId: options.resolveFormId ? () => optsRef.current.resolveFormId() : void 0,
124
- respondent: options.respondent
131
+ respondent: options.respondent,
132
+ // Challenge wiring read through optsRef so the engine (created once) sees
133
+ // the latest token/reset each submit — the widget solves after mount.
134
+ challengeRequired: options.challengeRequired,
135
+ getChallengeToken: () => optsRef.current.getChallengeToken?.(),
136
+ onChallengeFailed: () => optsRef.current.onChallengeFailed?.()
125
137
  });
126
138
  }
127
139
  const store = storeRef.current;
@@ -175,9 +187,10 @@ import {
175
187
  contentHash,
176
188
  FilloError,
177
189
  isCodeForm,
190
+ isFilloError,
178
191
  syncCodeForm
179
192
  } from "@usefillo/core";
180
- import { useEffect as useEffect2, useState } from "react";
193
+ import { useEffect as useEffect2, useRef as useRef2, useState } from "react";
181
194
  import { defineForm, isCodeForm as isCodeForm2, syncCodeForm as syncCodeForm2 } from "@usefillo/core";
182
195
  var noticedSync = /* @__PURE__ */ new Set();
183
196
  function noticeSync(handle, kind, message) {
@@ -186,39 +199,158 @@ function noticeSync(handle, kind, message) {
186
199
  noticedSync.add(key);
187
200
  console.info(message);
188
201
  }
189
- function useCodeFormSync(form, client, onError) {
202
+ function useCodeFormSync(form, client, onError, options) {
190
203
  const codeForm = isCodeForm(form) ? form : null;
191
- const [synced, setSynced] = useState({ formId: null, status: null });
204
+ const [synced, setSynced] = useState({
205
+ key: null,
206
+ client: void 0,
207
+ settled: false,
208
+ formId: null,
209
+ status: null,
210
+ error: null,
211
+ noticeError: null,
212
+ staged: false
213
+ });
192
214
  const hasKey = Boolean(client?.key);
215
+ const allowUnsynced = Boolean(options?.allowUnsynced);
193
216
  const handle = codeForm?.id;
194
217
  const contentKey = codeForm ? contentHash(JSON.stringify(codeForm.schema) + JSON.stringify(codeForm.theme ?? null)) : null;
218
+ const syncKey = codeForm ? client?.key ? `${client.baseUrl}|${client.key}|${codeForm.id}|${contentKey}` : allowUnsynced ? null : `${client?.baseUrl ?? ""}|<missing-key>|${codeForm.id}|${contentKey}` : null;
219
+ const onErrorRef = useRef2(onError);
220
+ onErrorRef.current = onError;
195
221
  useEffect2(() => {
196
- if (!codeForm || !client?.key) return;
222
+ if (!codeForm || !syncKey) return;
197
223
  let cancelled = false;
224
+ setSynced(
225
+ (previous) => previous.key === syncKey && previous.client === client ? {
226
+ ...previous,
227
+ settled: false,
228
+ formId: null,
229
+ status: null,
230
+ error: null,
231
+ noticeError: null,
232
+ staged: false,
233
+ resolvedSchema: void 0,
234
+ resolvedTheme: void 0
235
+ } : {
236
+ key: syncKey,
237
+ client,
238
+ settled: false,
239
+ formId: null,
240
+ status: null,
241
+ error: null,
242
+ noticeError: null,
243
+ staged: false
244
+ }
245
+ );
246
+ if (!client?.key) {
247
+ const error = new FilloError(
248
+ 'Code forms need createClient({ key: "pk_\u2026" }) to resolve the live form. Pass a publishable key or a published formId.',
249
+ 401,
250
+ void 0,
251
+ "sync_key_required"
252
+ );
253
+ console.warn(`[fillo] form sync failed (${error.code}): ${error.message}`);
254
+ setSynced({
255
+ key: syncKey,
256
+ client,
257
+ settled: true,
258
+ formId: null,
259
+ status: null,
260
+ error,
261
+ noticeError: null,
262
+ staged: false
263
+ });
264
+ onErrorRef.current?.(error);
265
+ return;
266
+ }
198
267
  syncCodeForm(client, codeForm).then((r) => {
199
268
  if (cancelled) return;
200
- setSynced({ formId: r.formId, status: r.status ?? null });
269
+ if ((r.staged || r.syncError) && !r.resolvedSchema) {
270
+ throw new FilloError(
271
+ "Sync did not return the live schema needed to render safely. Publish changes before deploy, and update @usefillo/* or your API.",
272
+ 409,
273
+ void 0,
274
+ "sync_snapshot_required"
275
+ );
276
+ }
277
+ const noticeError = r.syncError ? new FilloError(r.syncError.message, 403, void 0, r.syncError.code) : null;
278
+ setSynced({
279
+ key: syncKey,
280
+ client,
281
+ settled: true,
282
+ formId: r.formId,
283
+ status: r.status ?? null,
284
+ branding: r.branding,
285
+ challenge: r.challenge,
286
+ error: null,
287
+ noticeError,
288
+ staged: Boolean(r.staged),
289
+ resolvedSchema: r.resolvedSchema,
290
+ resolvedTheme: r.resolvedTheme
291
+ });
201
292
  const dashboardUrl = `${client.baseUrl || "https://fillo.so"}/forms/${r.formId}`;
202
293
  if (r.warning) console.warn(`[fillo] ${r.warning}`);
294
+ if (noticeError) {
295
+ console.warn(
296
+ `[fillo] form sync needs attention (${noticeError.code}): ${noticeError.message}`
297
+ );
298
+ onErrorRef.current?.(noticeError);
299
+ }
203
300
  if (r.staged) {
204
- noticeSync(codeForm.id, "staged", `[fillo] "${codeForm.id}": changes staged as a draft \u2014 publish them at ${dashboardUrl}`);
301
+ noticeSync(
302
+ codeForm.id,
303
+ "staged",
304
+ `[fillo] "${codeForm.id}": changes staged as a draft \u2014 the live version remains rendered until you publish them at ${dashboardUrl}`
305
+ );
205
306
  } else if (r.status === "draft") {
206
307
  noticeSync(codeForm.id, "draft", `[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published: ${dashboardUrl}`);
207
308
  }
208
309
  }).catch((err) => {
209
- const error = err instanceof FilloError ? err : new FilloError(String(err), 0);
210
- console.warn("[fillo] form sync failed:", error.message);
211
- if (!cancelled) onError?.(error);
310
+ if (cancelled) return;
311
+ const error = isFilloError(err) ? err : new FilloError(String(err), 0);
312
+ console.warn(
313
+ `[fillo] form sync failed${error.code ? ` (${error.code})` : ""}: ${error.message}`
314
+ );
315
+ setSynced({
316
+ key: syncKey,
317
+ client,
318
+ settled: true,
319
+ formId: null,
320
+ status: null,
321
+ error,
322
+ noticeError: null,
323
+ staged: false
324
+ });
325
+ onErrorRef.current?.(error);
212
326
  });
213
327
  return () => {
214
328
  cancelled = true;
215
329
  };
216
- }, [hasKey, handle, contentKey, client, onError]);
217
- return synced;
330
+ }, [hasKey, handle, contentKey, client, syncKey, allowUnsynced]);
331
+ return synced.key === syncKey && synced.client === client ? {
332
+ formId: synced.formId,
333
+ status: synced.status,
334
+ branding: synced.branding,
335
+ challenge: synced.challenge,
336
+ error: synced.error,
337
+ noticeError: synced.noticeError,
338
+ syncing: Boolean(syncKey) && !synced.settled,
339
+ staged: synced.staged,
340
+ resolvedSchema: synced.resolvedSchema,
341
+ resolvedTheme: synced.resolvedTheme
342
+ } : {
343
+ formId: null,
344
+ status: null,
345
+ error: null,
346
+ noticeError: null,
347
+ syncing: Boolean(syncKey),
348
+ staged: false
349
+ };
218
350
  }
219
351
 
220
352
  // src/fields.tsx
221
- import { isField, pipeBlock, shouldAutoSubmit, slotClass as slotClass2, visibleFields } from "@usefillo/core";
353
+ import { isField, pipeBlock, REQUIRED_FIELD_MESSAGE, shouldAutoSubmit, slotClass as slotClass2, visibleFields } from "@usefillo/core";
222
354
  import { memo, useEffect as useEffect6, useMemo as useMemo4, useState as useState5 } from "react";
223
355
 
224
356
  // src/phone.tsx
@@ -230,9 +362,10 @@ import {
230
362
  flagEmoji,
231
363
  formatNational,
232
364
  parsePhone,
365
+ positionPhonePopover,
233
366
  toE164
234
367
  } from "@usefillo/core";
235
- import { useEffect as useEffect3, useLayoutEffect, useMemo as useMemo3, useRef as useRef2, useState as useState2 } from "react";
368
+ import { useEffect as useEffect3, useLayoutEffect, useMemo as useMemo3, useRef as useRef3, useState as useState2 } from "react";
236
369
  import { jsx, jsxs } from "react/jsx-runtime";
237
370
  function localeCountry() {
238
371
  if (typeof navigator === "undefined") return void 0;
@@ -253,31 +386,6 @@ function nationalFor(stored, country) {
253
386
  const digits = digitsOnly(stored);
254
387
  return digits.startsWith(country.dialCode) ? digits.slice(country.dialCode.length) : digits;
255
388
  }
256
- var PHONE_POPOVER_VIEWPORT_GAP = 8;
257
- function positionPhonePopover(anchor, popover) {
258
- if (!anchor || !popover || typeof window === "undefined") return "below";
259
- const gap = PHONE_POPOVER_VIEWPORT_GAP;
260
- const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
261
- const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
262
- const anchorRect = anchor.getBoundingClientRect();
263
- const spaceBelow = Math.max(0, viewportHeight - anchorRect.bottom - gap);
264
- const spaceAbove = Math.max(0, anchorRect.top - gap);
265
- const placement = spaceBelow < popover.scrollHeight && spaceAbove > spaceBelow ? "above" : "below";
266
- const availableHeight = placement === "above" ? spaceAbove : spaceBelow;
267
- const maxHeight = Math.max(0, Math.min(availableHeight, viewportHeight - gap * 2));
268
- const maxWidth = Math.max(0, viewportWidth - gap * 2);
269
- popover.style.setProperty("--fillo-phone-popover-max-height", `${Math.floor(maxHeight)}px`);
270
- popover.style.setProperty("--fillo-phone-popover-max-width", `${Math.floor(maxWidth)}px`);
271
- popover.style.setProperty("--fillo-phone-popover-offset-x", "0px");
272
- const width = Math.min(popover.offsetWidth, maxWidth);
273
- let offsetX = 0;
274
- const rightOverflow = anchorRect.left + width - (viewportWidth - gap);
275
- if (rightOverflow > 0) offsetX -= rightOverflow;
276
- const leftOverflow = gap - (anchorRect.left + offsetX);
277
- if (leftOverflow > 0) offsetX += leftOverflow;
278
- popover.style.setProperty("--fillo-phone-popover-offset-x", `${Math.round(offsetX)}px`);
279
- return placement;
280
- }
281
389
  function PhoneField({ field, value, error, setValue, ids: providedIds }) {
282
390
  const ids = useFilloFieldIds(field.id, providedIds);
283
391
  const slots = useFieldSlots(field, error);
@@ -287,7 +395,12 @@ function PhoneField({ field, value, error, setValue, ids: providedIds }) {
287
395
  const [country, setCountry] = useState2(
288
396
  () => parsePhone(stored).country ?? countryByIso(schema.defaultCountry) ?? PHONE_COUNTRIES[0]
289
397
  );
290
- const touched = useRef2(false);
398
+ const touched = useRef3(false);
399
+ useEffect3(() => {
400
+ if (touched.current || !stored) return;
401
+ const restoredCountry = parsePhone(stored).country;
402
+ if (restoredCountry) setCountry(restoredCountry);
403
+ }, [stored]);
291
404
  useEffect3(() => {
292
405
  if (touched.current) return;
293
406
  if (parsePhone(stored).country || countryByIso(schema.defaultCountry)) return;
@@ -296,8 +409,8 @@ function PhoneField({ field, value, error, setValue, ids: providedIds }) {
296
409
  }, []);
297
410
  const national = nationalFor(stored, country);
298
411
  const display = formatNational(country, national);
299
- const inputRef = useRef2(null);
300
- const caretDigits = useRef2(null);
412
+ const inputRef = useRef3(null);
413
+ const caretDigits = useRef3(null);
301
414
  useLayoutEffect(() => {
302
415
  const el = inputRef.current;
303
416
  if (!el || caretDigits.current == null) return;
@@ -375,10 +488,10 @@ function CountrySelect({
375
488
  const [query, setQuery] = useState2("");
376
489
  const [active, setActive] = useState2(0);
377
490
  const [placement, setPlacement] = useState2("below");
378
- const rootRef = useRef2(null);
379
- const triggerRef = useRef2(null);
380
- const popoverRef = useRef2(null);
381
- const searchRef = useRef2(null);
491
+ const rootRef = useRef3(null);
492
+ const triggerRef = useRef3(null);
493
+ const popoverRef = useRef3(null);
494
+ const searchRef = useRef3(null);
382
495
  const listId = `${labelledBy}-countries`;
383
496
  const matches = useMemo3(() => {
384
497
  const q = query.trim().toLowerCase();
@@ -448,6 +561,7 @@ function CountrySelect({
448
561
  className: "fillo-phone-flag",
449
562
  "aria-haspopup": "listbox",
450
563
  "aria-expanded": open,
564
+ "aria-controls": open ? listId : void 0,
451
565
  "aria-label": `Country: ${country.name} (+${country.dialCode})`,
452
566
  onClick: () => setOpen((o) => !o),
453
567
  children: [
@@ -471,6 +585,7 @@ function CountrySelect({
471
585
  "aria-expanded": "true",
472
586
  "aria-controls": listId,
473
587
  "aria-autocomplete": "list",
588
+ "aria-label": "Search country or code",
474
589
  "aria-activedescendant": matches[active] ? `${listId}-opt-${active}` : void 0,
475
590
  placeholder: "Search country or code",
476
591
  value: query,
@@ -512,15 +627,15 @@ function CountrySelect({
512
627
  }
513
628
 
514
629
  // src/signature.tsx
515
- import { useEffect as useEffect4, useRef as useRef3, useState as useState3 } from "react";
630
+ import { useEffect as useEffect4, useRef as useRef4, useState as useState3 } from "react";
516
631
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
517
632
  function SignatureField({ field, value, error, setValue, ids: providedIds }) {
518
633
  const ids = useFilloFieldIds(field.id, providedIds);
519
634
  const slots = useFieldSlots(field, error);
520
635
  const strings = useStrings();
521
636
  const typedInputId = `${ids.inputId}-typed`;
522
- const canvasRef = useRef3(null);
523
- const drawing = useRef3(false);
637
+ const canvasRef = useRef4(null);
638
+ const drawing = useRef4(false);
524
639
  const [hasInk, setHasInk] = useState3(Boolean(value));
525
640
  const [typedName, setTypedName] = useState3("");
526
641
  useEffect4(() => {
@@ -537,12 +652,31 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
537
652
  ctx.lineCap = "round";
538
653
  ctx.lineJoin = "round";
539
654
  ctx.strokeStyle = getComputedStyle(canvas).color;
655
+ }, []);
656
+ useEffect4(() => {
657
+ const canvas = canvasRef.current;
658
+ const ctx = canvas?.getContext("2d");
659
+ if (!canvas || !ctx) return;
660
+ let cancelled = false;
661
+ const rect = canvas.getBoundingClientRect();
540
662
  if (typeof value === "string" && value.startsWith("data:image/")) {
663
+ setHasInk(true);
541
664
  const img = new Image();
542
- img.onload = () => ctx.drawImage(img, 0, 0, rect.width, rect.height);
665
+ img.onload = () => {
666
+ if (cancelled) return;
667
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
668
+ ctx.drawImage(img, 0, 0, rect.width, rect.height);
669
+ };
543
670
  img.src = value;
671
+ } else {
672
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
673
+ setHasInk(false);
674
+ setTypedName("");
544
675
  }
545
- }, []);
676
+ return () => {
677
+ cancelled = true;
678
+ };
679
+ }, [value]);
546
680
  function point(e) {
547
681
  const rect = e.currentTarget.getBoundingClientRect();
548
682
  return { x: e.clientX - rect.left, y: e.clientY - rect.top };
@@ -622,6 +756,7 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
622
756
  onPointerDown: start,
623
757
  onPointerMove: move,
624
758
  onPointerUp: end,
759
+ onPointerCancel: end,
625
760
  onPointerLeave: end
626
761
  }
627
762
  ),
@@ -657,21 +792,15 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
657
792
  }
658
793
 
659
794
  // src/upload.tsx
660
- import { useEffect as useEffect5, useRef as useRef4, useState as useState4 } from "react";
795
+ import { useEffect as useEffect5, useRef as useRef5, useState as useState4 } from "react";
661
796
  import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
662
- function uploadStatus(inFlight, doneCount) {
797
+ function uploadStatus(inFlight, doneCount, strings) {
663
798
  const failed = inFlight.filter((f) => f.error).length;
664
799
  const active = inFlight.length - failed;
665
800
  const parts = [];
666
- if (active > 0) {
667
- parts.push(`Uploading ${active} ${active === 1 ? "file" : "files"}, please wait\u2026`);
668
- }
669
- if (failed > 0) {
670
- parts.push(`${failed} ${failed === 1 ? "upload" : "uploads"} failed.`);
671
- }
672
- if (active === 0 && doneCount > 0) {
673
- parts.push(`${doneCount} ${doneCount === 1 ? "file" : "files"} uploaded.`);
674
- }
801
+ if (active > 0) parts.push(strings.filesUploading(active));
802
+ if (failed > 0) parts.push(strings.uploadsFailed(failed));
803
+ if (active === 0 && doneCount > 0) parts.push(strings.filesUploaded(doneCount));
675
804
  return parts.join(" ");
676
805
  }
677
806
  function formatBytes(bytes) {
@@ -685,41 +814,49 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
685
814
  const slots = useFieldSlots(field, error);
686
815
  const strings = useStrings();
687
816
  const schema = field;
688
- const inputRef = useRef4(null);
817
+ const inputRef = useRef5(null);
689
818
  const [inFlight, setInFlight] = useState4([]);
690
819
  const [dragOver, setDragOver] = useState4(false);
691
- const mountedRef = useRef4(true);
692
- const controllers = useRef4(/* @__PURE__ */ new Map());
693
- const inFlightCount = useRef4(0);
694
- useEffect5(
695
- () => () => {
820
+ const mountedRef = useRef5(true);
821
+ const controllers = useRef5(/* @__PURE__ */ new Map());
822
+ const inFlightCount = useRef5(0);
823
+ const setUploading = api.setUploading;
824
+ const fieldId = field.id;
825
+ useEffect5(() => {
826
+ mountedRef.current = true;
827
+ return () => {
696
828
  mountedRef.current = false;
697
829
  controllers.current.forEach((c) => c.abort());
698
- },
699
- []
700
- );
830
+ if (inFlightCount.current > 0) setUploading(fieldId, false);
831
+ };
832
+ }, [fieldId, setUploading]);
701
833
  const files = Array.isArray(value) ? value : [];
834
+ const filesRef = useRef5(files);
835
+ filesRef.current = files;
702
836
  const maxFiles = schema.maxFiles ?? 1;
703
- const maxBytes = (schema.maxFileSizeMb ?? 500) * 1024 * 1024;
837
+ const maxMb = schema.maxFileSizeMb ?? 500;
838
+ const maxBytes = maxMb * 1024 * 1024;
704
839
  const canUpload = Boolean(api.client && api.formId);
705
- const remaining = maxFiles - files.length - inFlight.length;
840
+ const activeInFlight = inFlight.filter((f) => !f.error).length;
841
+ const remaining = maxFiles - files.length - activeInFlight;
706
842
  async function startUpload(file) {
843
+ if (maxFiles - filesRef.current.length - inFlightCount.current <= 0) return;
707
844
  const key = `${file.name}-${file.size}-${Math.floor(performance.now())}`;
708
845
  if (file.size > maxBytes) {
709
846
  setInFlight((prev) => [
710
847
  ...prev,
711
- { key, name: file.name, size: file.size, fraction: 0, error: `Larger than ${schema.maxFileSizeMb ?? 500} MB limit` }
848
+ { key, name: file.name, size: file.size, fraction: 0, file, tooLarge: true, error: strings.fileTooLarge(maxMb) }
712
849
  ]);
713
850
  return;
714
851
  }
715
- setInFlight((prev) => [...prev, { key, name: file.name, size: file.size, fraction: 0 }]);
852
+ setInFlight((prev) => [...prev, { key, name: file.name, size: file.size, fraction: 0, file }]);
716
853
  inFlightCount.current += 1;
717
- api.setUploading(field.id, true);
854
+ setUploading(fieldId, true);
718
855
  const controller = new AbortController();
719
856
  controllers.current.set(key, controller);
720
857
  try {
721
858
  const uploaded = await api.client.uploadFile(api.formId, file, {
722
- fieldId: field.id,
859
+ fieldId,
723
860
  signal: controller.signal,
724
861
  onProgress: ({ fraction }) => {
725
862
  if (mountedRef.current) {
@@ -729,11 +866,12 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
729
866
  });
730
867
  if (!mountedRef.current) return;
731
868
  setInFlight((prev) => prev.filter((f) => f.key !== key));
732
- const current = Array.isArray(api.data[field.id]) ? api.data[field.id] : [];
733
- setValue([...current, uploaded]);
869
+ const next = [...filesRef.current, uploaded].slice(0, maxFiles);
870
+ filesRef.current = next;
871
+ setValue(next);
734
872
  } catch (err) {
735
873
  if (mountedRef.current) {
736
- const message = err instanceof Error && err.name === "FilloError" && err.message ? err.message : "Upload failed \u2014 try again";
874
+ const message = err instanceof Error && err.name === "FilloError" && err.message ? err.message : strings.uploadFailed;
737
875
  setInFlight(
738
876
  (prev) => prev.map((f) => f.key === key ? { ...f, error: message } : f)
739
877
  );
@@ -741,7 +879,7 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
741
879
  } finally {
742
880
  controllers.current.delete(key);
743
881
  inFlightCount.current -= 1;
744
- if (mountedRef.current && inFlightCount.current === 0) api.setUploading(field.id, false);
882
+ if (mountedRef.current && inFlightCount.current === 0) setUploading(fieldId, false);
745
883
  }
746
884
  }
747
885
  function cancelUpload(key) {
@@ -750,11 +888,14 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
750
888
  }
751
889
  function handleFiles(list) {
752
890
  if (!list) return;
753
- Array.from(list).slice(0, Math.max(0, remaining)).forEach((file) => void startUpload(file));
891
+ const currentRoom = Math.max(0, maxFiles - filesRef.current.length - inFlightCount.current);
892
+ Array.from(list).slice(0, currentRoom).forEach((file) => void startUpload(file));
754
893
  if (inputRef.current) inputRef.current.value = "";
755
894
  }
756
895
  function removeFile(fileId) {
757
- setValue(files.filter((f) => f.fileId !== fileId));
896
+ const next = filesRef.current.filter((f) => f.fileId !== fileId);
897
+ filesRef.current = next;
898
+ setValue(next);
758
899
  }
759
900
  const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ") || void 0;
760
901
  return /* @__PURE__ */ jsxs3("div", { ...slots.wrapperProps(`fillo-field fillo-field--file_upload${error ? " fillo-field--error" : ""}`), children: [
@@ -782,12 +923,16 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
782
923
  onClick: () => canUpload && inputRef.current?.click(),
783
924
  role: "button",
784
925
  tabIndex: 0,
926
+ "aria-disabled": !canUpload || void 0,
785
927
  "aria-labelledby": ids.labelId,
786
928
  "aria-required": field.required ? true : void 0,
787
929
  "aria-invalid": error ? true : void 0,
788
930
  "aria-describedby": describedBy,
789
931
  onKeyDown: (e) => {
790
- if ((e.key === "Enter" || e.key === " ") && canUpload) inputRef.current?.click();
932
+ if (e.key === "Enter" || e.key === " ") {
933
+ e.preventDefault();
934
+ if (canUpload) inputRef.current?.click();
935
+ }
791
936
  },
792
937
  children: [
793
938
  /* @__PURE__ */ jsx3(
@@ -797,23 +942,16 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
797
942
  id: ids.inputId,
798
943
  type: "file",
799
944
  hidden: true,
945
+ disabled: !canUpload,
800
946
  multiple: maxFiles > 1,
801
947
  accept: schema.accept?.join(","),
802
948
  onChange: (e) => canUpload && handleFiles(e.target.files)
803
949
  }
804
950
  ),
805
951
  canUpload ? /* @__PURE__ */ jsxs3(Fragment, { children: [
806
- /* @__PURE__ */ jsxs3("span", { className: "fillo-dropzone-title", children: [
807
- "Drop ",
808
- maxFiles > 1 ? "files" : "a file",
809
- " here or click to browse"
810
- ] }),
811
- /* @__PURE__ */ jsxs3("span", { className: "fillo-dropzone-hint", children: [
812
- "Up to ",
813
- schema.maxFileSizeMb ?? 500,
814
- " MB per file \xB7 resumable"
815
- ] })
816
- ] }) : /* @__PURE__ */ jsx3("span", { className: "fillo-dropzone-hint", children: "Uploads are disabled in preview" })
952
+ /* @__PURE__ */ jsx3("span", { className: "fillo-dropzone-title", children: strings.dropzoneTitle(maxFiles > 1) }),
953
+ /* @__PURE__ */ jsx3("span", { className: "fillo-dropzone-hint", children: strings.dropzoneHint(maxMb) })
954
+ ] }) : /* @__PURE__ */ jsx3("span", { className: "fillo-dropzone-hint", children: strings.uploadsDisabled })
817
955
  ]
818
956
  }
819
957
  ),
@@ -824,7 +962,7 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
824
962
  "aria-live": "polite",
825
963
  className: "fillo-sr-only",
826
964
  style: { position: "absolute", left: "-9999px", width: 1, height: 1, overflow: "hidden" },
827
- children: uploadStatus(inFlight, files.length)
965
+ children: uploadStatus(inFlight, files.length, strings)
828
966
  }
829
967
  ),
830
968
  (inFlight.length > 0 || files.length > 0) && /* @__PURE__ */ jsxs3("ul", { className: "fillo-files", children: [
@@ -857,6 +995,21 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
857
995
  /* @__PURE__ */ jsx3("span", { className: "fillo-file-name", children: f.name }),
858
996
  f.error ? /* @__PURE__ */ jsxs3(Fragment, { children: [
859
997
  /* @__PURE__ */ jsx3("span", { className: "fillo-file-meta", children: f.error }),
998
+ f.file && !f.tooLarge && remaining > 0 && /* @__PURE__ */ jsx3(
999
+ "button",
1000
+ {
1001
+ type: "button",
1002
+ className: "fillo-file-retry",
1003
+ "aria-label": `${strings.uploadRetry} ${f.name}`,
1004
+ onClick: () => {
1005
+ const retryFile = f.file;
1006
+ if (!retryFile) return;
1007
+ setInFlight((prev) => prev.filter((x) => x.key !== f.key));
1008
+ void startUpload(retryFile);
1009
+ },
1010
+ children: strings.uploadRetry
1011
+ }
1012
+ ),
860
1013
  /* @__PURE__ */ jsx3(
861
1014
  "button",
862
1015
  {
@@ -975,6 +1128,9 @@ function TextInput({ field, value, error, setValue, ids: providedIds }) {
975
1128
  type,
976
1129
  value: typeof value === "string" || typeof value === "number" ? String(value) : "",
977
1130
  placeholder: field.placeholder,
1131
+ maxLength: "maxLength" in field ? field.maxLength : void 0,
1132
+ min: field.kind === "number" ? field.min : void 0,
1133
+ max: field.kind === "number" ? field.max : void 0,
978
1134
  onChange: (e) => setValue(e.target.value),
979
1135
  ...fieldAria(field, error, ids)
980
1136
  }
@@ -991,6 +1147,7 @@ function LongText({ field, value, error, setValue, ids: providedIds }) {
991
1147
  rows: 4,
992
1148
  value: typeof value === "string" ? value : "",
993
1149
  placeholder: field.placeholder,
1150
+ maxLength: "maxLength" in field ? field.maxLength : void 0,
994
1151
  onChange: (e) => setValue(e.target.value),
995
1152
  ...fieldAria(field, error, ids)
996
1153
  }
@@ -1246,12 +1403,18 @@ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
1246
1403
  }
1247
1404
  ) });
1248
1405
  }
1249
- var OTHER_SENTINEL = "__fw_other__";
1406
+ function otherSentinel(field) {
1407
+ let value = "__fillo_other__";
1408
+ const ids = new Set(field.options.map((option) => option.id));
1409
+ while (ids.has(value)) value += "_";
1410
+ return value;
1411
+ }
1250
1412
  function Dropdown({ field, value, error, setValue, ids: providedIds }) {
1251
1413
  const ids = useFilloFieldIds(field.id, providedIds);
1252
1414
  const strings = useStrings();
1253
1415
  const choice = field;
1254
1416
  const options = useDisplayOptions(choice);
1417
+ const otherValue = otherSentinel(choice);
1255
1418
  const isOtherValue = typeof value === "string" && value !== "" && !choice.options.some((o) => o.id === value);
1256
1419
  const [otherOn, setOtherOn] = useState5(isOtherValue);
1257
1420
  const otherActive = isOtherValue || value === "" && otherOn;
@@ -1264,9 +1427,9 @@ function Dropdown({ field, value, error, setValue, ids: providedIds }) {
1264
1427
  id: ids.inputId,
1265
1428
  ...control,
1266
1429
  ...fieldAria(field, error, ids),
1267
- value: otherActive ? OTHER_SENTINEL : typeof value === "string" ? value : "",
1430
+ value: otherActive ? otherValue : typeof value === "string" ? value : "",
1268
1431
  onChange: (e) => {
1269
- if (e.target.value === OTHER_SENTINEL) {
1432
+ if (e.target.value === otherValue) {
1270
1433
  setOtherOn(true);
1271
1434
  setValue("");
1272
1435
  } else {
@@ -1277,7 +1440,7 @@ function Dropdown({ field, value, error, setValue, ids: providedIds }) {
1277
1440
  children: [
1278
1441
  /* @__PURE__ */ jsx4("option", { value: "", children: field.placeholder ?? strings.choosePlaceholder }),
1279
1442
  options.map((opt) => /* @__PURE__ */ jsx4("option", { value: opt.id, children: opt.label }, opt.id)),
1280
- choice.allowOther && /* @__PURE__ */ jsx4("option", { value: OTHER_SENTINEL, children: `${strings.other}\u2026` })
1443
+ choice.allowOther && /* @__PURE__ */ jsx4("option", { value: otherValue, children: `${strings.other}\u2026` })
1281
1444
  ]
1282
1445
  }
1283
1446
  ),
@@ -1301,17 +1464,15 @@ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
1301
1464
  const ids = useFilloFieldIds(field.id, providedIds);
1302
1465
  const strings = useStrings();
1303
1466
  const appearance = useFilloAppearance();
1467
+ const slots = useFieldSlots(field, error);
1304
1468
  const checkbox = field;
1305
1469
  if (checkbox.appearance === "toggle") {
1306
1470
  return /* @__PURE__ */ jsxs4(
1307
1471
  "div",
1308
1472
  {
1309
- className: `fillo-field fillo-field--checkbox fillo-field--toggle${error ? " fillo-field--error" : ""}`,
1310
- "data-fillo": "field",
1311
- "data-field": field.id,
1312
- "data-kind": field.kind,
1313
- "data-invalid": error ? "" : void 0,
1314
- "data-required": field.required ? "" : void 0,
1473
+ ...slots.wrapperProps(
1474
+ `fillo-field fillo-field--checkbox fillo-field--toggle${error ? " fillo-field--error" : ""}`
1475
+ ),
1315
1476
  children: [
1316
1477
  /* @__PURE__ */ jsxs4("label", { className: "fillo-toggle", "data-fillo": "option", "data-checked": value === true ? "" : void 0, children: [
1317
1478
  /* @__PURE__ */ jsx4("span", { className: "fillo-toggle-copy", children: /* @__PURE__ */ jsxs4("span", { className: slotClass2("fillo-option-label", appearance, { slot: "optionLabel", kind: field.kind, fieldId: field.id }), "data-fillo": "optionLabel", children: [
@@ -1333,8 +1494,8 @@ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
1333
1494
  ),
1334
1495
  /* @__PURE__ */ jsx4("span", { className: "fillo-toggle-track", "aria-hidden": "true", children: /* @__PURE__ */ jsx4("span", { className: "fillo-toggle-thumb" }) })
1335
1496
  ] }),
1336
- field.description && /* @__PURE__ */ jsx4("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
1337
- error && /* @__PURE__ */ jsx4("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
1497
+ field.description && /* @__PURE__ */ jsx4("p", { className: slots.description, "data-fillo": "fieldDescription", id: ids.descriptionId, children: field.description }),
1498
+ error && /* @__PURE__ */ jsx4("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, role: "alert", children: error })
1338
1499
  ]
1339
1500
  }
1340
1501
  );
@@ -1342,12 +1503,9 @@ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
1342
1503
  return /* @__PURE__ */ jsxs4(
1343
1504
  "div",
1344
1505
  {
1345
- className: `fillo-field fillo-field--checkbox${error ? " fillo-field--error" : ""}`,
1346
- "data-fillo": "field",
1347
- "data-field": field.id,
1348
- "data-kind": field.kind,
1349
- "data-invalid": error ? "" : void 0,
1350
- "data-required": field.required ? "" : void 0,
1506
+ ...slots.wrapperProps(
1507
+ `fillo-field fillo-field--checkbox${error ? " fillo-field--error" : ""}`
1508
+ ),
1351
1509
  children: [
1352
1510
  /* @__PURE__ */ jsxs4("label", { className: "fillo-option", "data-fillo": "option", "data-checked": value === true ? "" : void 0, children: [
1353
1511
  /* @__PURE__ */ jsx4(
@@ -1366,8 +1524,8 @@ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
1366
1524
  !field.required && /* @__PURE__ */ jsx4("span", { className: "fillo-optional", children: strings.optional })
1367
1525
  ] })
1368
1526
  ] }),
1369
- field.description && /* @__PURE__ */ jsx4("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
1370
- error && /* @__PURE__ */ jsx4("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
1527
+ field.description && /* @__PURE__ */ jsx4("p", { className: slots.description, "data-fillo": "fieldDescription", id: ids.descriptionId, children: field.description }),
1528
+ error && /* @__PURE__ */ jsx4("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, role: "alert", children: error })
1371
1529
  ]
1372
1530
  }
1373
1531
  );
@@ -1513,73 +1671,106 @@ function Ranking({ field, value, error, setValue, ids: providedIds }) {
1513
1671
  [next[index], next[target]] = [next[target], next[index]];
1514
1672
  setValue(next);
1515
1673
  }
1516
- return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4("ol", { className: "fillo-ranking", children: order.map((id, index) => {
1517
- const option = ranking.options.find((o) => o.id === id);
1518
- if (!option) return null;
1519
- return /* @__PURE__ */ jsxs4("li", { className: "fillo-ranking-item", children: [
1520
- /* @__PURE__ */ jsx4("span", { className: "fillo-ranking-index", children: index + 1 }),
1521
- /* @__PURE__ */ jsx4("span", { className: "fillo-ranking-label", children: option.label }),
1522
- /* @__PURE__ */ jsxs4("span", { className: "fillo-ranking-controls", children: [
1523
- /* @__PURE__ */ jsx4(
1524
- "button",
1525
- {
1526
- type: "button",
1527
- className: "fillo-ranking-move",
1528
- "aria-label": `Move ${option.label} up`,
1529
- disabled: index === 0,
1530
- onClick: () => move(id, -1),
1531
- children: "\u2191"
1532
- }
1533
- ),
1534
- /* @__PURE__ */ jsx4(
1535
- "button",
1536
- {
1537
- type: "button",
1538
- className: "fillo-ranking-move",
1539
- "aria-label": `Move ${option.label} down`,
1540
- disabled: index === order.length - 1,
1541
- onClick: () => move(id, 1),
1542
- children: "\u2193"
1543
- }
1544
- )
1545
- ] })
1546
- ] }, id);
1547
- }) }) });
1674
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4(
1675
+ "div",
1676
+ {
1677
+ id: ids.inputId,
1678
+ role: "group",
1679
+ "aria-labelledby": ids.labelId,
1680
+ ...fieldAria(field, error, ids),
1681
+ children: /* @__PURE__ */ jsx4("ol", { className: "fillo-ranking", children: order.map((id, index) => {
1682
+ const option = ranking.options.find((o) => o.id === id);
1683
+ if (!option) return null;
1684
+ return /* @__PURE__ */ jsxs4("li", { className: "fillo-ranking-item", children: [
1685
+ /* @__PURE__ */ jsx4("span", { className: "fillo-ranking-index", children: index + 1 }),
1686
+ /* @__PURE__ */ jsx4("span", { className: "fillo-ranking-label", children: option.label }),
1687
+ /* @__PURE__ */ jsxs4("span", { className: "fillo-ranking-controls", children: [
1688
+ /* @__PURE__ */ jsx4(
1689
+ "button",
1690
+ {
1691
+ type: "button",
1692
+ className: "fillo-ranking-move",
1693
+ "aria-label": `Move ${option.label} up`,
1694
+ disabled: index === 0,
1695
+ onClick: () => move(id, -1),
1696
+ children: "\u2191"
1697
+ }
1698
+ ),
1699
+ /* @__PURE__ */ jsx4(
1700
+ "button",
1701
+ {
1702
+ type: "button",
1703
+ className: "fillo-ranking-move",
1704
+ "aria-label": `Move ${option.label} down`,
1705
+ disabled: index === order.length - 1,
1706
+ onClick: () => move(id, 1),
1707
+ children: "\u2193"
1708
+ }
1709
+ )
1710
+ ] })
1711
+ ] }, id);
1712
+ }) })
1713
+ }
1714
+ ) });
1548
1715
  }
1549
1716
  function Matrix({ field, value, error, setValue, ids: providedIds }) {
1550
1717
  const ids = useFilloFieldIds(field.id, providedIds);
1551
1718
  const matrix = field;
1552
1719
  const answers = value && typeof value === "object" && !Array.isArray(value) ? value : {};
1553
- return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4("div", { className: "fillo-matrix-wrap", children: /* @__PURE__ */ jsxs4("table", { className: "fillo-matrix", children: [
1554
- /* @__PURE__ */ jsx4("thead", { children: /* @__PURE__ */ jsxs4("tr", { children: [
1555
- /* @__PURE__ */ jsx4("th", {}),
1556
- matrix.columns.map((col) => /* @__PURE__ */ jsx4("th", { scope: "col", children: col.label }, col.id))
1557
- ] }) }),
1558
- /* @__PURE__ */ jsx4("tbody", { children: matrix.rows.map((row) => {
1559
- const rowLabelId = `${ids.inputId}-${row.id}`;
1560
- const answered = answers[row.id] !== void 0;
1561
- const rowInvalid = Boolean(error) && field.required && !answered;
1562
- return (
1563
- // Each row is its own radiogroup, labelled by the row question.
1564
- /* @__PURE__ */ jsxs4("tr", { role: "radiogroup", "aria-labelledby": rowLabelId, children: [
1565
- /* @__PURE__ */ jsx4("th", { scope: "row", id: rowLabelId, children: row.label }),
1566
- matrix.columns.map((col) => /* @__PURE__ */ jsx4("td", { children: /* @__PURE__ */ jsx4(
1567
- "input",
1568
- {
1569
- type: "radio",
1570
- className: "fillo-option-input",
1571
- name: `${ids.name}-${row.id}`,
1572
- "aria-label": `${row.label}: ${col.label}`,
1573
- "aria-required": field.required ? true : void 0,
1574
- "aria-invalid": rowInvalid ? true : void 0,
1575
- checked: answers[row.id] === col.id,
1576
- onChange: () => setValue({ ...answers, [row.id]: col.id })
1577
- }
1578
- ) }, col.id))
1579
- ] }, row.id)
1580
- );
1581
- }) })
1582
- ] }) }) });
1720
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4(
1721
+ "div",
1722
+ {
1723
+ id: ids.inputId,
1724
+ className: "fillo-matrix-wrap",
1725
+ role: "group",
1726
+ "aria-labelledby": ids.labelId,
1727
+ ...fieldAria(field, error, ids),
1728
+ children: /* @__PURE__ */ jsxs4("table", { className: "fillo-matrix", children: [
1729
+ /* @__PURE__ */ jsx4("thead", { children: /* @__PURE__ */ jsxs4("tr", { children: [
1730
+ /* @__PURE__ */ jsx4("th", {}),
1731
+ matrix.columns.map((col) => /* @__PURE__ */ jsx4("th", { scope: "col", children: col.label }, col.id))
1732
+ ] }) }),
1733
+ /* @__PURE__ */ jsx4("tbody", { children: matrix.rows.map((row) => {
1734
+ const rowLabelId = `${ids.inputId}-${row.id}`;
1735
+ const answered = answers[row.id] !== void 0;
1736
+ const rowInvalid = Boolean(error) && field.required && !answered;
1737
+ const radioId = (col) => `${ids.inputId}-${row.id}-${col.id}`;
1738
+ return (
1739
+ // The <tr> keeps its native row role so the grid's row/column
1740
+ // header association survives for screen readers. The row's
1741
+ // radiogroup lives on an inner element that aria-owns the radios
1742
+ // scattered across the cells (they can't be DOM-nested inside a
1743
+ // single element within a table row).
1744
+ /* @__PURE__ */ jsxs4("tr", { children: [
1745
+ /* @__PURE__ */ jsx4("th", { scope: "row", children: /* @__PURE__ */ jsx4(
1746
+ "div",
1747
+ {
1748
+ role: "radiogroup",
1749
+ "aria-labelledby": rowLabelId,
1750
+ "aria-owns": matrix.columns.map(radioId).join(" "),
1751
+ children: /* @__PURE__ */ jsx4("span", { id: rowLabelId, children: row.label })
1752
+ }
1753
+ ) }),
1754
+ matrix.columns.map((col) => /* @__PURE__ */ jsx4("td", { children: /* @__PURE__ */ jsx4(
1755
+ "input",
1756
+ {
1757
+ id: radioId(col),
1758
+ type: "radio",
1759
+ className: "fillo-option-input",
1760
+ name: `${ids.name}-${row.id}`,
1761
+ "aria-label": `${row.label}: ${col.label}`,
1762
+ "aria-required": field.required ? true : void 0,
1763
+ "aria-invalid": rowInvalid ? true : void 0,
1764
+ checked: answers[row.id] === col.id,
1765
+ onChange: () => setValue({ ...answers, [row.id]: col.id })
1766
+ }
1767
+ ) }, col.id))
1768
+ ] }, row.id)
1769
+ );
1770
+ }) })
1771
+ ] })
1772
+ }
1773
+ ) });
1583
1774
  }
1584
1775
  function Hidden() {
1585
1776
  return null;
@@ -1633,8 +1824,8 @@ function blockPropsEqual(prev, next) {
1633
1824
  const id = next.block.id;
1634
1825
  if (prev.api.data[id] !== next.api.data[id]) return false;
1635
1826
  if (prev.api.errors[id] !== next.api.errors[id]) return false;
1636
- if (next.block.kind === "custom") {
1637
- return prev.api.data === next.api.data && prev.api.status === next.api.status && prev.api.uploading === next.api.uploading && prev.api.isFirstPage === next.api.isFirstPage && prev.api.isLastPage === next.api.isLastPage && prev.api.pageIndex === next.api.pageIndex;
1827
+ if (next.block.kind === "custom" || isField(next.block) && Boolean(next.components?.[next.block.kind])) {
1828
+ return prev.api === next.api;
1638
1829
  }
1639
1830
  if ((blockHasPiping(prev.block) || blockHasPiping(next.block)) && prev.api.data !== next.api.data) {
1640
1831
  return false;
@@ -1649,6 +1840,7 @@ function blockPropsEqual(prev, next) {
1649
1840
  function BlockRendererInner({ block, api, components, customComponents }) {
1650
1841
  block = pipeBlock(block, api.data, api.form);
1651
1842
  const ids = useFilloFieldIds(block.id);
1843
+ const strings = useStrings();
1652
1844
  if (!isField(block)) return /* @__PURE__ */ jsx4(ContentRenderer, { block });
1653
1845
  let Component;
1654
1846
  if (block.kind === "custom") {
@@ -1665,12 +1857,14 @@ function BlockRendererInner({ block, api, components, customComponents }) {
1665
1857
  } else {
1666
1858
  Component = components?.[block.kind] ?? DEFAULT_COMPONENTS[block.kind];
1667
1859
  }
1860
+ const rawError = api.errors[block.id];
1861
+ const error = rawError === REQUIRED_FIELD_MESSAGE ? strings.required : rawError;
1668
1862
  return /* @__PURE__ */ jsx4(
1669
1863
  Component,
1670
1864
  {
1671
1865
  field: block,
1672
1866
  value: api.data[block.id],
1673
- error: api.errors[block.id],
1867
+ error,
1674
1868
  setValue: (v) => {
1675
1869
  api.setValue(block.id, v);
1676
1870
  const ctx = {
@@ -1713,8 +1907,127 @@ function FormField({
1713
1907
  );
1714
1908
  }
1715
1909
 
1910
+ // src/turnstile.tsx
1911
+ import { useEffect as useEffect7, useRef as useRef6 } from "react";
1912
+ import { jsx as jsx5 } from "react/jsx-runtime";
1913
+ var SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
1914
+ var SCRIPT_ID = "fillo-turnstile-script";
1915
+ var scriptPromise = null;
1916
+ function turnstileGlobal() {
1917
+ return globalThis.turnstile;
1918
+ }
1919
+ function loadTurnstileScript() {
1920
+ if (typeof window === "undefined" || typeof document === "undefined") {
1921
+ return Promise.reject(new Error("Turnstile needs a browser"));
1922
+ }
1923
+ if (turnstileGlobal()) return Promise.resolve();
1924
+ if (scriptPromise) return scriptPromise;
1925
+ scriptPromise = new Promise((resolve, reject) => {
1926
+ const onReady = () => {
1927
+ if (turnstileGlobal()) resolve();
1928
+ else reject(new Error("Turnstile loaded without its global"));
1929
+ };
1930
+ const existing = document.getElementById(SCRIPT_ID);
1931
+ if (existing) {
1932
+ if (turnstileGlobal()) {
1933
+ resolve();
1934
+ return;
1935
+ }
1936
+ existing.addEventListener("load", onReady, { once: true });
1937
+ existing.addEventListener(
1938
+ "error",
1939
+ () => reject(new Error("Turnstile script failed to load")),
1940
+ { once: true }
1941
+ );
1942
+ return;
1943
+ }
1944
+ const script = document.createElement("script");
1945
+ script.id = SCRIPT_ID;
1946
+ script.src = SCRIPT_SRC;
1947
+ script.async = true;
1948
+ script.defer = true;
1949
+ script.addEventListener("load", onReady, { once: true });
1950
+ script.addEventListener(
1951
+ "error",
1952
+ () => {
1953
+ script.remove();
1954
+ scriptPromise = null;
1955
+ reject(new Error("Turnstile script failed to load"));
1956
+ },
1957
+ { once: true }
1958
+ );
1959
+ document.head.appendChild(script);
1960
+ });
1961
+ return scriptPromise;
1962
+ }
1963
+ function TurnstileWidget({
1964
+ siteKey,
1965
+ onToken,
1966
+ onError,
1967
+ registerReset,
1968
+ className
1969
+ }) {
1970
+ const containerRef = useRef6(null);
1971
+ const widgetIdRef = useRef6(null);
1972
+ const onTokenRef = useRef6(onToken);
1973
+ onTokenRef.current = onToken;
1974
+ const onErrorRef = useRef6(onError);
1975
+ onErrorRef.current = onError;
1976
+ const registerResetRef = useRef6(registerReset);
1977
+ registerResetRef.current = registerReset;
1978
+ useEffect7(() => {
1979
+ let cancelled = false;
1980
+ loadTurnstileScript().then(() => {
1981
+ if (cancelled || !containerRef.current) return;
1982
+ const api = turnstileGlobal();
1983
+ if (!api) {
1984
+ onErrorRef.current?.();
1985
+ return;
1986
+ }
1987
+ const clearAndReset = () => {
1988
+ onTokenRef.current(void 0);
1989
+ if (widgetIdRef.current) api.reset(widgetIdRef.current);
1990
+ };
1991
+ widgetIdRef.current = api.render(containerRef.current, {
1992
+ sitekey: siteKey,
1993
+ theme: "auto",
1994
+ callback: (token) => onTokenRef.current(token),
1995
+ "error-callback": () => {
1996
+ onTokenRef.current(void 0);
1997
+ onErrorRef.current?.();
1998
+ },
1999
+ "expired-callback": clearAndReset,
2000
+ "timeout-callback": clearAndReset
2001
+ });
2002
+ registerResetRef.current?.(clearAndReset);
2003
+ }).catch(() => {
2004
+ if (!cancelled) onErrorRef.current?.();
2005
+ });
2006
+ return () => {
2007
+ cancelled = true;
2008
+ const api = turnstileGlobal();
2009
+ if (widgetIdRef.current && api) {
2010
+ try {
2011
+ api.remove(widgetIdRef.current);
2012
+ } catch {
2013
+ }
2014
+ }
2015
+ widgetIdRef.current = null;
2016
+ onTokenRef.current(void 0);
2017
+ };
2018
+ }, [siteKey]);
2019
+ return /* @__PURE__ */ jsx5(
2020
+ "div",
2021
+ {
2022
+ ref: containerRef,
2023
+ className,
2024
+ "data-fillo": "turnstile"
2025
+ }
2026
+ );
2027
+ }
2028
+
1716
2029
  // src/FilloForm.tsx
1717
- import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
2030
+ import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1718
2031
  function themeProps(theme) {
1719
2032
  const safeTheme = normalizeFormTheme(theme);
1720
2033
  if (!safeTheme) return {};
@@ -1749,7 +2062,9 @@ function badgeUrl(href) {
1749
2062
  function focusFirstInvalid(formEl) {
1750
2063
  if (!formEl || typeof window === "undefined") return;
1751
2064
  const run = () => {
1752
- const target = formEl.querySelector('[aria-invalid="true"]');
2065
+ const invalid = formEl.querySelector('[aria-invalid="true"]');
2066
+ const focusable = 'input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])';
2067
+ const target = invalid?.matches(focusable) ? invalid : invalid?.querySelector(focusable);
1753
2068
  target?.focus?.();
1754
2069
  };
1755
2070
  if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
@@ -1777,35 +2092,56 @@ function FilloForm(props) {
1777
2092
  () => props.client ?? (formId ? createClient() : void 0),
1778
2093
  [props.client, formId]
1779
2094
  );
1780
- const [fetched, setFetched] = useState6(null);
1781
- const [fetchedTheme, setFetchedTheme] = useState6(null);
1782
- const [loadError, setLoadError] = useState6(null);
1783
- const [closed, setClosed] = useState6(false);
1784
- const [fetchedBranding, setFetchedBranding] = useState6(null);
2095
+ const [loaded, setLoaded] = useState6(null);
2096
+ const [loadFailure, setLoadFailure] = useState6(null);
1785
2097
  const codeForm = isCodeForm2(form) ? form : null;
1786
- const inlineSchema = codeForm ? codeForm.schema : form;
1787
2098
  const onError = props.onError;
1788
- const { formId: syncedFormId, status: syncedStatus } = useCodeFormSync(form, client, onError);
1789
- const onErrorRef = useRef5(onError);
1790
- useEffect7(() => {
2099
+ const {
2100
+ formId: syncedFormId,
2101
+ status: syncedStatus,
2102
+ branding: syncedBranding,
2103
+ challenge: syncedChallenge,
2104
+ error: syncError,
2105
+ noticeError: syncNoticeError,
2106
+ syncing: codeFormSyncing,
2107
+ staged: codeChangesStaged,
2108
+ resolvedSchema,
2109
+ resolvedTheme
2110
+ } = useCodeFormSync(form, client, onError, {
2111
+ // An absent client is intentional render-only use. Builder previews and an
2112
+ // explicit canonical id also do not need publishable-key resolution.
2113
+ allowUnsynced: Boolean(!client || props.skipValidation || formId)
2114
+ });
2115
+ const renderResolvedSnapshot = Boolean(resolvedSchema && !isDevEnv());
2116
+ const inlineSchema = codeForm ? renderResolvedSnapshot ? resolvedSchema : codeForm.schema : form;
2117
+ const onErrorRef = useRef7(onError);
2118
+ useEffect8(() => {
1791
2119
  onErrorRef.current = onError;
1792
2120
  });
1793
2121
  const needsFetch = !inlineSchema && Boolean(client && formId);
1794
- useEffect7(() => {
1795
- if (!needsFetch || !client || !formId) return;
2122
+ useEffect8(() => {
2123
+ if (!needsFetch || !client || !formId) {
2124
+ setLoaded(null);
2125
+ setLoadFailure(null);
2126
+ return;
2127
+ }
1796
2128
  let cancelled = false;
1797
- setFetched(null);
1798
- setLoadError(null);
2129
+ setLoaded(null);
2130
+ setLoadFailure(null);
1799
2131
  client.getForm(formId).then((published) => {
1800
2132
  if (cancelled) return;
1801
- setFetched(published.schema);
1802
- setFetchedTheme(published.theme);
1803
- setClosed(Boolean(published.closed));
1804
- setFetchedBranding(published.branding ?? null);
2133
+ setLoaded({
2134
+ formId,
2135
+ schema: published.schema,
2136
+ theme: published.theme,
2137
+ closed: Boolean(published.closed),
2138
+ branding: published.branding ?? null,
2139
+ challenge: published.challenge ?? null
2140
+ });
1805
2141
  }).catch((err) => {
1806
2142
  if (cancelled) return;
1807
2143
  const error = err instanceof FilloError2 ? err : new FilloError2("This form could not be loaded.", 0);
1808
- setLoadError(error);
2144
+ setLoadFailure({ formId, error });
1809
2145
  onErrorRef.current?.(error);
1810
2146
  });
1811
2147
  return () => {
@@ -1813,32 +2149,53 @@ function FilloForm(props) {
1813
2149
  };
1814
2150
  }, [needsFetch, client, formId]);
1815
2151
  const strings = resolveStrings(props.strings);
1816
- const rawSchema = inlineSchema ?? fetched;
2152
+ const currentLoaded = needsFetch && loaded?.formId === formId ? loaded : null;
2153
+ const loadError = needsFetch && loadFailure && loadFailure.formId === formId ? loadFailure.error : null;
2154
+ const rawSchema = inlineSchema ?? currentLoaded?.schema ?? null;
1817
2155
  const normalized = useMemo5(() => rawSchema ? normalizeFormSchema(rawSchema) : null, [rawSchema]);
1818
2156
  const schema = normalized?.ok ? normalized.schema : null;
1819
- const theme = props.appearance?.theme ?? props.theme ?? codeForm?.theme ?? fetchedTheme ?? void 0;
2157
+ const resolvedCodeTheme = renderResolvedSnapshot ? resolvedTheme ?? void 0 : codeForm?.theme;
2158
+ const theme = props.appearance?.theme ?? props.theme ?? resolvedCodeTheme ?? currentLoaded?.theme ?? void 0;
1820
2159
  if (normalized && !normalized.ok) {
1821
2160
  const error = new FilloError2(`This form could not be rendered: ${normalized.error}`, 422);
1822
- if (props.renderError) return /* @__PURE__ */ jsx5(Fragment2, { children: props.renderError(error) });
1823
- return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--error ${props.className ?? ""}`, children: strings.renderFailed });
2161
+ if (props.renderError) return /* @__PURE__ */ jsx6(Fragment2, { children: props.renderError(error) });
2162
+ return /* @__PURE__ */ jsx6("div", { className: `fillo-form fillo-form--error ${props.className ?? ""}`, children: strings.renderFailed });
1824
2163
  }
1825
2164
  if (loadError) {
1826
2165
  if (props.renderError) {
1827
- return /* @__PURE__ */ jsx5(Fragment2, { children: props.renderError(loadError) });
2166
+ return /* @__PURE__ */ jsx6(Fragment2, { children: props.renderError(loadError) });
1828
2167
  }
1829
2168
  const message = loadError.status === 404 ? strings.loadFailedNotFound : loadError.status === 0 ? strings.loadFailedNetwork : strings.loadFailed;
1830
- return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--error ${props.className ?? ""}`, children: message });
2169
+ return /* @__PURE__ */ jsx6("div", { className: `fillo-form fillo-form--error ${props.className ?? ""}`, children: message });
1831
2170
  }
1832
- if (closed) {
1833
- return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--closed ${props.className ?? ""}`, ...themeProps(theme), children: /* @__PURE__ */ jsx5("p", { className: "fillo-closed", children: strings.closed }) });
2171
+ if (currentLoaded?.closed) {
2172
+ return /* @__PURE__ */ jsx6("div", { className: `fillo-form fillo-form--closed ${props.className ?? ""}`, ...themeProps(theme), children: /* @__PURE__ */ jsx6("p", { className: "fillo-closed", children: strings.closed }) });
2173
+ }
2174
+ const canonicalSyncError = codeForm && syncError && !props.skipValidation ? syncError : null;
2175
+ if (canonicalSyncError && props.renderError) {
2176
+ return /* @__PURE__ */ jsx6(Fragment2, { children: props.renderError(canonicalSyncError) });
2177
+ }
2178
+ if (canonicalSyncError && !isDevEnv()) {
2179
+ return /* @__PURE__ */ jsx6(
2180
+ "div",
2181
+ {
2182
+ className: `fillo-form fillo-form--error ${props.className ?? ""}`,
2183
+ role: "alert",
2184
+ "data-fillo": "root",
2185
+ "data-state": "error",
2186
+ ...themeProps(theme),
2187
+ children: strings.loadFailed
2188
+ }
2189
+ );
1834
2190
  }
1835
2191
  const notPublished = Boolean(codeForm) && syncedStatus === "draft" && !props.skipValidation;
1836
2192
  if (notPublished && !isDevEnv()) {
1837
2193
  const error = new FilloError2("This form isn't published yet.", 403);
1838
- if (props.renderError) return /* @__PURE__ */ jsx5(Fragment2, { children: props.renderError(error) });
1839
- return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--closed ${props.className ?? ""}`, ...themeProps(theme), children: /* @__PURE__ */ jsx5("p", { className: "fillo-closed", children: strings.notLive }) });
2194
+ if (props.renderError) return /* @__PURE__ */ jsx6(Fragment2, { children: props.renderError(error) });
2195
+ return /* @__PURE__ */ jsx6("div", { className: `fillo-form fillo-form--closed ${props.className ?? ""}`, ...themeProps(theme), children: /* @__PURE__ */ jsx6("p", { className: "fillo-closed", children: strings.notLive }) });
1840
2196
  }
1841
- if (!schema) {
2197
+ const awaitingCanonical = Boolean(codeForm && codeFormSyncing && !props.skipValidation);
2198
+ if (!isDevEnv() && awaitingCanonical || !schema) {
1842
2199
  return /* @__PURE__ */ jsxs5(
1843
2200
  "div",
1844
2201
  {
@@ -1846,15 +2203,17 @@ function FilloForm(props) {
1846
2203
  "aria-busy": "true",
1847
2204
  ...themeProps(theme),
1848
2205
  children: [
1849
- /* @__PURE__ */ jsx5("div", { className: "fillo-skeleton" }),
1850
- /* @__PURE__ */ jsx5("div", { className: "fillo-skeleton" }),
1851
- /* @__PURE__ */ jsx5("div", { className: "fillo-skeleton fillo-skeleton--short" })
2206
+ /* @__PURE__ */ jsx6("div", { className: "fillo-skeleton" }),
2207
+ /* @__PURE__ */ jsx6("div", { className: "fillo-skeleton" }),
2208
+ /* @__PURE__ */ jsx6("div", { className: "fillo-skeleton fillo-skeleton--short" })
1852
2209
  ]
1853
2210
  }
1854
2211
  );
1855
2212
  }
1856
- const poweredBy = fetchedBranding?.poweredBy ?? true;
1857
- return /* @__PURE__ */ jsx5(
2213
+ const branding = codeForm ? syncedBranding : currentLoaded?.branding;
2214
+ const poweredBy = branding?.poweredBy ?? true;
2215
+ const challenge = props.challenge ?? (codeForm ? syncedChallenge : currentLoaded?.challenge) ?? void 0;
2216
+ return /* @__PURE__ */ jsx6(
1858
2217
  ResolvedForm,
1859
2218
  {
1860
2219
  ...props,
@@ -1863,44 +2222,128 @@ function FilloForm(props) {
1863
2222
  theme,
1864
2223
  formId: syncedFormId ?? formId,
1865
2224
  poweredBy,
1866
- brandLabel: fetchedBranding?.label,
1867
- brandHref: fetchedBranding?.href,
1868
- draftNotice: notPublished
2225
+ brandLabel: branding?.label,
2226
+ brandHref: branding?.href,
2227
+ draftNotice: notPublished,
2228
+ stagedNotice: codeChangesStaged,
2229
+ syncErrorNotice: syncError ?? syncNoticeError ?? void 0,
2230
+ challenge
1869
2231
  }
1870
2232
  );
1871
2233
  }
1872
2234
  function PoweredBy({ label, href }) {
1873
- return /* @__PURE__ */ jsx5("a", { className: "fillo-powered", href: badgeUrl(href), target: "_blank", rel: "noopener noreferrer", children: label ?? "Powered by Fillo" });
2235
+ return /* @__PURE__ */ jsx6("a", { className: "fillo-powered", href: badgeUrl(href), target: "_blank", rel: "noopener noreferrer", children: label ?? "Powered by Fillo" });
1874
2236
  }
1875
2237
  function isDevEnv() {
1876
2238
  return typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
1877
2239
  }
1878
2240
  function DevNoClientNotice() {
1879
2241
  return /* @__PURE__ */ jsxs5("div", { className: "fillo-devwarning", role: "alert", children: [
1880
- /* @__PURE__ */ jsx5("strong", { children: "No Fillo client connected." }),
2242
+ /* @__PURE__ */ jsx6("strong", { children: "No Fillo client connected." }),
1881
2243
  " This form is render-only and won\u2019t save responses. Pass a ",
1882
- /* @__PURE__ */ jsx5("code", { children: "client" }),
2244
+ /* @__PURE__ */ jsx6("code", { children: "client" }),
1883
2245
  " to collect them in Fillo, or forward them with webhooks. (This notice only appears in development.)"
1884
2246
  ] });
1885
2247
  }
1886
2248
  function DevDraftNotice() {
1887
2249
  return /* @__PURE__ */ jsxs5("div", { className: "fillo-devwarning", role: "alert", children: [
1888
- /* @__PURE__ */ jsx5("strong", { children: "Draft form." }),
2250
+ /* @__PURE__ */ jsx6("strong", { children: "Draft form." }),
1889
2251
  " It renders here, but it won\u2019t accept responses until you publish it in your Fillo dashboard. (This notice only appears in development.)"
1890
2252
  ] });
1891
2253
  }
2254
+ function DevStagedNotice() {
2255
+ return /* @__PURE__ */ jsxs5("div", { className: "fillo-devwarning", role: "alert", children: [
2256
+ /* @__PURE__ */ jsx6("strong", { children: "Code changes are staged." }),
2257
+ " This local preview is not the live form yet. Publish the changes in Fillo before deploying them for respondents."
2258
+ ] });
2259
+ }
2260
+ function DevSyncErrorNotice({ error }) {
2261
+ return /* @__PURE__ */ jsxs5("div", { className: "fillo-devwarning", role: "alert", children: [
2262
+ /* @__PURE__ */ jsxs5("strong", { children: [
2263
+ "Form sync needs attention",
2264
+ error.code ? ` (${error.code})` : "",
2265
+ "."
2266
+ ] }),
2267
+ " ",
2268
+ error.message,
2269
+ " The safe form remains available for development."
2270
+ ] });
2271
+ }
1892
2272
  function ResolvedForm(props) {
1893
2273
  const { schema, theme } = props;
1894
2274
  const instanceId = useId2().replace(/:/g, "");
1895
- const hpRef = useRef5("");
2275
+ const hpRef = useRef7(null);
1896
2276
  const codeForm = isCodeForm2(props.form) ? props.form : null;
1897
2277
  const client = props.client;
2278
+ const challenge = props.challenge;
2279
+ const challengeRequired = Boolean(challenge);
2280
+ const challengeTokenRef = useRef7(void 0);
2281
+ const [challengeToken, setChallengeToken] = useState6(void 0);
2282
+ const [challengeError, setChallengeError] = useState6(false);
2283
+ const resetChallengeRef = useRef7(void 0);
2284
+ const handleChallengeToken = useCallback((token) => {
2285
+ challengeTokenRef.current = token;
2286
+ setChallengeToken(token);
2287
+ if (token) setChallengeError(false);
2288
+ }, []);
2289
+ const registerChallengeReset = useCallback((reset) => {
2290
+ resetChallengeRef.current = reset;
2291
+ }, []);
1898
2292
  const resolveFormId = useMemo5(
1899
2293
  () => codeForm && client?.key ? (
1900
2294
  // bypassCache: a stale cached formId must not be trusted at submit time.
1901
- () => syncCodeForm2(client, codeForm, { bypassCache: true }).then((r) => r.formId)
2295
+ async () => {
2296
+ try {
2297
+ const result = await syncCodeForm2(client, codeForm, { bypassCache: true });
2298
+ if (result.status === "draft") {
2299
+ throw new FilloError2(
2300
+ "This form is no longer published. Submission was stopped before sending answers.",
2301
+ 403,
2302
+ void 0,
2303
+ "form_not_published"
2304
+ );
2305
+ }
2306
+ const authoritative = normalizeFormSchema(result.resolvedSchema ?? codeForm.schema);
2307
+ if (!authoritative.ok) {
2308
+ throw new FilloError2(
2309
+ `The canonical form schema was invalid: ${authoritative.error}`,
2310
+ 502,
2311
+ void 0,
2312
+ "invalid_sync_schema"
2313
+ );
2314
+ }
2315
+ if (!formSchemasEqual(schema, authoritative.schema)) {
2316
+ throw new FilloError2(
2317
+ "The live form changed after this page loaded. Submission stopped before incompatible answers were sent. Reload to use the published schema.",
2318
+ 409,
2319
+ void 0,
2320
+ "form_schema_changed"
2321
+ );
2322
+ }
2323
+ if (result.syncError) {
2324
+ const notice = new FilloError2(
2325
+ result.syncError.message,
2326
+ 403,
2327
+ void 0,
2328
+ result.syncError.code
2329
+ );
2330
+ console.warn(
2331
+ `[fillo] form sync needs attention (${notice.code}): ${notice.message}`
2332
+ );
2333
+ props.onError?.(notice);
2334
+ }
2335
+ return result.formId;
2336
+ } catch (error) {
2337
+ const failure = isFilloError2(error) ? error : new FilloError2(error instanceof Error ? error.message : String(error), 0);
2338
+ console.warn(
2339
+ `[fillo] form sync failed${failure.code ? ` (${failure.code})` : ""}: ${failure.message}`
2340
+ );
2341
+ props.onError?.(failure);
2342
+ throw failure;
2343
+ }
2344
+ }
1902
2345
  ) : void 0,
1903
- [codeForm, client]
2346
+ [codeForm, client, props.onError, schema]
1904
2347
  );
1905
2348
  const api = useFilloController({
1906
2349
  form: schema,
@@ -1910,30 +2353,36 @@ function ResolvedForm(props) {
1910
2353
  onChange: props.onChange,
1911
2354
  onSubmitted: props.onSubmitted,
1912
2355
  skipValidation: props.skipValidation,
1913
- getHoneypot: () => hpRef.current,
2356
+ getHoneypot: () => hpRef.current?.value ?? "",
1914
2357
  // Framed renderer: Fillo draws the layout (and badge), so the surface is
1915
2358
  // "default", never the bare-engine "headless".
1916
2359
  surface: "default",
1917
2360
  resolveFormId,
1918
- respondent: props.respondent
2361
+ respondent: props.respondent,
2362
+ challengeRequired,
2363
+ getChallengeToken: () => challengeTokenRef.current,
2364
+ onChallengeFailed: () => {
2365
+ handleChallengeToken(void 0);
2366
+ resetChallengeRef.current?.();
2367
+ }
1919
2368
  });
1920
2369
  useDraftFlush(api);
1921
2370
  const themed = useMemo5(() => themeProps(theme), [theme]);
1922
2371
  const settings = schema.settings;
1923
2372
  const submitted = api.status === "submitted";
1924
2373
  const liveSubmit = submitted && !api.restoredSubmission;
1925
- useEffect7(() => {
2374
+ useEffect8(() => {
1926
2375
  if (!liveSubmit || !settings.redirectUrl || typeof window === "undefined") return;
1927
2376
  const url = safeHttpUrl(settings.redirectUrl);
1928
2377
  if (url) window.location.assign(url);
1929
2378
  }, [liveSubmit, settings.redirectUrl]);
1930
- const successRef = useRef5(null);
1931
- useEffect7(() => {
2379
+ const successRef = useRef7(null);
2380
+ useEffect8(() => {
1932
2381
  if (liveSubmit) successRef.current?.focus();
1933
2382
  }, [liveSubmit]);
1934
- const blocksRef = useRef5(null);
1935
- const prevPageIndex = useRef5(api.pageIndex);
1936
- useEffect7(() => {
2383
+ const blocksRef = useRef7(null);
2384
+ const prevPageIndex = useRef7(api.pageIndex);
2385
+ useEffect8(() => {
1937
2386
  if (prevPageIndex.current === api.pageIndex) return;
1938
2387
  prevPageIndex.current = api.pageIndex;
1939
2388
  blocksRef.current?.focus();
@@ -1963,22 +2412,29 @@ function ResolvedForm(props) {
1963
2412
  className: slotClass3("fillo-success", appearance, { slot: "success", status: "submitted" }),
1964
2413
  "data-fillo": "success",
1965
2414
  children: [
1966
- /* @__PURE__ */ jsx5("div", { className: "fillo-success-mark", "aria-hidden": "true", children: "\u2713" }),
1967
- /* @__PURE__ */ jsx5("h2", { className: "fillo-success-title", children: settings.successTitle ?? strings.successTitle }),
1968
- /* @__PURE__ */ jsx5("p", { className: "fillo-success-message", children: settings.successMessage ?? strings.successMessage })
2415
+ /* @__PURE__ */ jsx6("div", { className: "fillo-success-mark", "aria-hidden": "true", children: "\u2713" }),
2416
+ /* @__PURE__ */ jsx6("h2", { className: "fillo-success-title", children: settings.successTitle ?? strings.successTitle }),
2417
+ /* @__PURE__ */ jsx6("p", { className: "fillo-success-message", children: api.duplicateSubmission ? strings.alreadyAnswered : settings.successMessage ?? strings.successMessage })
1969
2418
  ]
1970
2419
  }
1971
2420
  ),
1972
- props.poweredBy && /* @__PURE__ */ jsx5(PoweredBy, { label: props.brandLabel, href: props.brandHref })
2421
+ props.poweredBy && /* @__PURE__ */ jsx6(PoweredBy, { label: props.brandLabel, href: props.brandHref })
1973
2422
  ]
1974
2423
  }
1975
2424
  );
1976
2425
  }
1977
2426
  const multiPage = api.pageCount > 1;
1978
2427
  const autoSubmit = settings.submitMode === "auto";
1979
- const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields2(api.form, api.data));
2428
+ const showChallenge = challengeRequired && api.isLastPage;
2429
+ const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(reachableFields(api.form, api.data)) || // A challenge needs an explicit Submit even on an otherwise button-less
2430
+ // auto-submit form — you can't one-tap past a human check.
2431
+ showChallenge;
1980
2432
  const showFooter = showPrimaryButton || multiPage && !api.isFirstPage;
1981
- return /* @__PURE__ */ jsx5(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx5(FilloStringsContext.Provider, { value: strings, children: /* @__PURE__ */ jsx5(FilloAppearanceContext.Provider, { value: appearance, children: /* @__PURE__ */ jsx5(FilloInstanceIdContext.Provider, { value: instanceId, children: /* @__PURE__ */ jsxs5(
2433
+ const progressSeq = reachablePageSequence(api.form, api.data);
2434
+ const progressTotal = Math.max(progressSeq.length, 1);
2435
+ const progressPos = progressSeq.indexOf(api.page.id);
2436
+ const progressStep = progressPos >= 0 ? progressPos + 1 : Math.min(api.pageIndex + 1, progressTotal);
2437
+ return /* @__PURE__ */ jsx6(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx6(FilloStringsContext.Provider, { value: strings, children: /* @__PURE__ */ jsx6(FilloAppearanceContext.Provider, { value: appearance, children: /* @__PURE__ */ jsx6(FilloInstanceIdContext.Provider, { value: instanceId, children: /* @__PURE__ */ jsxs5(
1982
2438
  "form",
1983
2439
  {
1984
2440
  className: slotClass3(`fillo-form ${props.className ?? ""}`, appearance, {
@@ -2003,8 +2459,10 @@ function ResolvedForm(props) {
2003
2459
  }
2004
2460
  },
2005
2461
  children: [
2006
- !props.client && !props.skipValidation && isDevEnv() && /* @__PURE__ */ jsx5(DevNoClientNotice, {}),
2007
- props.draftNotice && isDevEnv() && /* @__PURE__ */ jsx5(DevDraftNotice, {}),
2462
+ !props.client && !props.skipValidation && isDevEnv() && /* @__PURE__ */ jsx6(DevNoClientNotice, {}),
2463
+ props.draftNotice && isDevEnv() && /* @__PURE__ */ jsx6(DevDraftNotice, {}),
2464
+ props.stagedNotice && isDevEnv() && /* @__PURE__ */ jsx6(DevStagedNotice, {}),
2465
+ props.syncErrorNotice && isDevEnv() && /* @__PURE__ */ jsx6(DevSyncErrorNotice, { error: props.syncErrorNotice }),
2008
2466
  (api.resumedDraft || api.editingPrevious) && /* @__PURE__ */ jsxs5(
2009
2467
  "div",
2010
2468
  {
@@ -2012,33 +2470,43 @@ function ResolvedForm(props) {
2012
2470
  "data-fillo": "resume",
2013
2471
  role: "status",
2014
2472
  children: [
2015
- /* @__PURE__ */ jsx5("span", { className: "fillo-resume-text", children: api.editingPrevious ? strings.editNotice : strings.resumeNotice }),
2016
- /* @__PURE__ */ jsx5("button", { type: "button", className: "fillo-resume-clear", onClick: api.resetDraft, children: strings.resumeStartOver })
2473
+ /* @__PURE__ */ jsx6("span", { className: "fillo-resume-text", children: api.editingPrevious ? strings.editNotice : strings.resumeNotice }),
2474
+ /* @__PURE__ */ jsx6("button", { type: "button", className: "fillo-resume-clear", onClick: api.resetDraft, children: strings.resumeStartOver })
2017
2475
  ]
2018
2476
  }
2019
2477
  ),
2020
- multiPage && settings.showProgress !== false && /* @__PURE__ */ jsx5(
2478
+ api.resumeLinkFailed && !api.resumedDraft && !api.editingPrevious && /* @__PURE__ */ jsx6(
2479
+ "div",
2480
+ {
2481
+ className: slotClass3("fillo-resume fillo-resume--expired", appearance, { slot: "resume" }),
2482
+ "data-fillo": "resume",
2483
+ role: "status",
2484
+ children: /* @__PURE__ */ jsx6("span", { className: "fillo-resume-text", children: strings.resumeLinkExpired })
2485
+ }
2486
+ ),
2487
+ multiPage && settings.showProgress !== false && /* @__PURE__ */ jsx6(
2021
2488
  "div",
2022
2489
  {
2023
2490
  className: slotClass3("fillo-progress-track", appearance, { slot: "progress" }),
2024
2491
  "data-fillo": "progress",
2025
2492
  role: "progressbar",
2026
- "aria-valuemin": 0,
2027
- "aria-valuemax": api.pageCount,
2028
- "aria-valuenow": api.pageIndex + 1,
2029
- children: /* @__PURE__ */ jsx5(
2493
+ "aria-label": schema.title || "Form progress",
2494
+ "aria-valuemin": 1,
2495
+ "aria-valuemax": progressTotal,
2496
+ "aria-valuenow": progressStep,
2497
+ children: /* @__PURE__ */ jsx6(
2030
2498
  "div",
2031
2499
  {
2032
2500
  className: slotClass3("fillo-progress-fill", appearance, { slot: "progressFill" }),
2033
2501
  "data-fillo": "progressFill",
2034
- style: { "--fillo-progress-value": `${(api.pageIndex + 1) / api.pageCount * 100}%` }
2502
+ style: { "--fillo-progress-value": `${progressStep / progressTotal * 100}%` }
2035
2503
  }
2036
2504
  )
2037
2505
  }
2038
2506
  ),
2039
2507
  api.pageIndex === 0 && props.showTitle !== false && /* @__PURE__ */ jsxs5("header", { className: slotClass3("fillo-header", appearance, { slot: "header" }), "data-fillo": "header", children: [
2040
- schema.title && /* @__PURE__ */ jsx5("h1", { className: slotClass3("fillo-title", appearance, { slot: "title" }), "data-fillo": "title", children: schema.title }),
2041
- schema.description && /* @__PURE__ */ jsx5(
2508
+ schema.title && /* @__PURE__ */ jsx6("h1", { className: slotClass3("fillo-title", appearance, { slot: "title" }), "data-fillo": "title", children: schema.title }),
2509
+ schema.description && /* @__PURE__ */ jsx6(
2042
2510
  "p",
2043
2511
  {
2044
2512
  className: slotClass3("fillo-form-description", appearance, { slot: "description" }),
@@ -2047,8 +2515,8 @@ function ResolvedForm(props) {
2047
2515
  }
2048
2516
  )
2049
2517
  ] }),
2050
- multiPage && api.page.title && api.pageIndex > 0 && /* @__PURE__ */ jsx5("h2", { className: slotClass3("fillo-page-title", appearance, { slot: "pageTitle" }), "data-fillo": "pageTitle", children: api.page.title }),
2051
- /* @__PURE__ */ jsx5("div", { className: slotClass3("fillo-blocks", appearance, { slot: "blocks" }), "data-fillo": "blocks", ref: blocksRef, tabIndex: -1, children: api.blocks.map((block) => /* @__PURE__ */ jsx5(
2518
+ multiPage && api.page.title && api.pageIndex > 0 && /* @__PURE__ */ jsx6("h2", { className: slotClass3("fillo-page-title", appearance, { slot: "pageTitle" }), "data-fillo": "pageTitle", children: api.page.title }),
2519
+ /* @__PURE__ */ jsx6("div", { className: slotClass3("fillo-blocks", appearance, { slot: "blocks" }), "data-fillo": "blocks", ref: blocksRef, tabIndex: -1, children: api.blocks.map((block) => /* @__PURE__ */ jsx6(
2052
2520
  BlockRenderer,
2053
2521
  {
2054
2522
  block,
@@ -2058,7 +2526,7 @@ function ResolvedForm(props) {
2058
2526
  },
2059
2527
  block.id
2060
2528
  )) }),
2061
- /* @__PURE__ */ jsx5(
2529
+ /* @__PURE__ */ jsx6(
2062
2530
  "input",
2063
2531
  {
2064
2532
  type: "text",
@@ -2069,15 +2537,28 @@ function ResolvedForm(props) {
2069
2537
  "aria-hidden": "true",
2070
2538
  defaultValue: "",
2071
2539
  style: HP_STYLE,
2072
- ref: assertHpHidden,
2073
- onChange: (e) => {
2074
- hpRef.current = e.target.value;
2540
+ ref: (element) => {
2541
+ hpRef.current = element;
2542
+ assertHpHidden(element);
2075
2543
  }
2076
2544
  }
2077
2545
  ),
2078
- (api.status === "error" || api.submitError) && /* @__PURE__ */ jsx5("p", { className: "fillo-submit-error", role: "alert", children: api.submitError ?? strings.submitFailed }),
2546
+ challenge && showChallenge && /* @__PURE__ */ jsxs5("div", { className: "fillo-turnstile-slot", "data-fillo": "turnstile-slot", children: [
2547
+ /* @__PURE__ */ jsx6(
2548
+ TurnstileWidget,
2549
+ {
2550
+ siteKey: challenge.siteKey,
2551
+ className: slotClass3("fillo-turnstile", appearance, { slot: "turnstile" }),
2552
+ onToken: handleChallengeToken,
2553
+ onError: () => setChallengeError(true),
2554
+ registerReset: registerChallengeReset
2555
+ }
2556
+ ),
2557
+ challengeError && /* @__PURE__ */ jsx6("p", { className: "fillo-turnstile-error", role: "alert", children: strings.challengeUnavailable })
2558
+ ] }),
2559
+ (api.status === "error" || api.submitError) && /* @__PURE__ */ jsx6("p", { className: "fillo-submit-error", role: "alert", children: api.submitError ?? strings.submitFailed }),
2079
2560
  showFooter && /* @__PURE__ */ jsxs5("footer", { className: slotClass3("fillo-footer", appearance, { slot: "footer" }), "data-fillo": "footer", children: [
2080
- multiPage && !api.isFirstPage && /* @__PURE__ */ jsx5(
2561
+ multiPage && !api.isFirstPage && /* @__PURE__ */ jsx6(
2081
2562
  "button",
2082
2563
  {
2083
2564
  type: "button",
@@ -2087,38 +2568,41 @@ function ResolvedForm(props) {
2087
2568
  children: strings.back
2088
2569
  }
2089
2570
  ),
2090
- showPrimaryButton && /* @__PURE__ */ jsx5(
2571
+ showPrimaryButton && /* @__PURE__ */ jsx6(
2091
2572
  "button",
2092
2573
  {
2093
2574
  type: "submit",
2094
2575
  className: slotClass3("fillo-button fillo-button--primary", appearance, { slot: "button", variant: "primary" }),
2095
2576
  "data-fillo": "button",
2096
- disabled: api.status === "submitting" || api.uploading,
2577
+ disabled: api.status === "submitting" || api.uploading || // Wait for the human check before enabling submit (never
2578
+ // "Next"). The server rejects a tokenless submit regardless —
2579
+ // this is UX only.
2580
+ showChallenge && !challengeToken,
2097
2581
  children: api.uploading ? strings.uploading : api.status === "submitting" ? strings.submitting : api.isLastPage ? settings.submitLabel ?? strings.submit : strings.next
2098
2582
  }
2099
2583
  )
2100
2584
  ] }),
2101
- props.poweredBy && /* @__PURE__ */ jsx5(PoweredBy, { label: props.brandLabel, href: props.brandHref })
2585
+ props.poweredBy && /* @__PURE__ */ jsx6(PoweredBy, { label: props.brandLabel, href: props.brandHref })
2102
2586
  ]
2103
2587
  }
2104
2588
  ) }) }) }) });
2105
2589
  }
2106
2590
 
2107
2591
  // src/jsx.tsx
2108
- import { useRef as useRef6 } from "react";
2592
+ import { useRef as useRef8 } from "react";
2109
2593
  import {
2110
2594
  codeFormFromJsx,
2111
2595
  FilloJsxError,
2112
2596
  JSX_BLOCK_COMPONENTS
2113
2597
  } from "@usefillo/core";
2114
- import { jsx as jsx6 } from "react/jsx-runtime";
2598
+ import { jsx as jsx7 } from "react/jsx-runtime";
2115
2599
  var B = JSX_BLOCK_COMPONENTS;
2116
2600
  var isDevEnv2 = () => typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
2117
2601
  function JsxForm(props) {
2118
2602
  const { id, title, description, settings, theme, client, children, ...rest } = props;
2119
2603
  const compiled = codeFormFromJsx({ id, title, description, settings, theme }, children);
2120
2604
  const json = JSON.stringify(compiled.schema) + JSON.stringify(compiled.theme ?? null);
2121
- const memo2 = useRef6(null);
2605
+ const memo2 = useRef8(null);
2122
2606
  if (memo2.current && memo2.current.json !== json && isDevEnv2()) {
2123
2607
  console.warn(
2124
2608
  `[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.`
@@ -2127,7 +2611,7 @@ function JsxForm(props) {
2127
2611
  if (!memo2.current || memo2.current.json !== json) {
2128
2612
  memo2.current = { json, form: compiled };
2129
2613
  }
2130
- return /* @__PURE__ */ jsx6(FilloForm, { form: memo2.current.form, client, ...rest });
2614
+ return /* @__PURE__ */ jsx7(FilloForm, { form: memo2.current.form, client, ...rest });
2131
2615
  }
2132
2616
  function defineFormFromJsx(element) {
2133
2617
  if (!element || element.type !== JsxForm) {
@@ -2172,30 +2656,121 @@ var Fillo = {
2172
2656
 
2173
2657
  // src/provider.tsx
2174
2658
  import { useId as useId3, useMemo as useMemo6 } from "react";
2175
- import { FilloError as FilloError3, normalizeFormSchema as normalizeFormSchema2, resolveStrings as resolveStrings2 } from "@usefillo/core";
2176
- import { jsx as jsx7 } from "react/jsx-runtime";
2177
- function FilloProvider({ children, form, formId, client, appearance, strings, ...options }) {
2659
+ import {
2660
+ FilloError as FilloError3,
2661
+ formSchemasEqual as formSchemasEqual2,
2662
+ isFilloError as isFilloError3,
2663
+ normalizeFormSchema as normalizeFormSchema2,
2664
+ resolveStrings as resolveStrings2,
2665
+ syncCodeForm as syncCodeForm3
2666
+ } from "@usefillo/core";
2667
+ import { Fragment as Fragment3, jsx as jsx8 } from "react/jsx-runtime";
2668
+ function FilloProvider({
2669
+ children,
2670
+ form,
2671
+ formId,
2672
+ client,
2673
+ appearance,
2674
+ strings,
2675
+ onError,
2676
+ renderError,
2677
+ ...options
2678
+ }) {
2178
2679
  const instanceId = useId3().replace(/:/g, "");
2179
2680
  const codeForm = isCodeForm2(form) ? form : null;
2180
- const rawSchema = codeForm ? codeForm.schema : form;
2181
- const schemaKey = useMemo6(() => JSON.stringify(rawSchema), [rawSchema]);
2182
- const normalized = useMemo6(() => normalizeFormSchema2(rawSchema), [rawSchema, schemaKey]);
2681
+ const {
2682
+ formId: syncedFormId,
2683
+ status: syncedStatus,
2684
+ error: syncError,
2685
+ syncing: codeFormSyncing,
2686
+ resolvedSchema
2687
+ } = useCodeFormSync(form, client, onError, {
2688
+ allowUnsynced: Boolean(!client || formId || options.skipValidation)
2689
+ });
2690
+ const rawSchema = codeForm ? resolvedSchema && !isDevEnv3() ? resolvedSchema : codeForm.schema : form;
2691
+ const normalized = useMemo6(() => normalizeFormSchema2(rawSchema), [rawSchema]);
2183
2692
  if (!normalized.ok) {
2184
2693
  throw new FilloError3(`This form could not be rendered: ${normalized.error}`, 422);
2185
2694
  }
2186
2695
  const schema = normalized.schema;
2187
- const { formId: syncedFormId } = useCodeFormSync(form, client);
2696
+ const resolveFormId = useMemo6(
2697
+ () => codeForm && client?.key ? async () => {
2698
+ try {
2699
+ const result = await syncCodeForm3(client, codeForm, { bypassCache: true });
2700
+ if (result.status === "draft") {
2701
+ throw new FilloError3(
2702
+ "This form is no longer published. Submission was stopped before sending answers.",
2703
+ 403,
2704
+ void 0,
2705
+ "form_not_published"
2706
+ );
2707
+ }
2708
+ const authoritative = normalizeFormSchema2(result.resolvedSchema ?? codeForm.schema);
2709
+ if (!authoritative.ok) {
2710
+ throw new FilloError3(
2711
+ `The canonical form schema was invalid: ${authoritative.error}`,
2712
+ 502,
2713
+ void 0,
2714
+ "invalid_sync_schema"
2715
+ );
2716
+ }
2717
+ if (!formSchemasEqual2(schema, authoritative.schema)) {
2718
+ throw new FilloError3(
2719
+ "The live form changed after this page loaded. Submission stopped before incompatible answers were sent. Reload to use the published schema.",
2720
+ 409,
2721
+ void 0,
2722
+ "form_schema_changed"
2723
+ );
2724
+ }
2725
+ if (result.syncError) {
2726
+ const notice = new FilloError3(
2727
+ result.syncError.message,
2728
+ 403,
2729
+ void 0,
2730
+ result.syncError.code
2731
+ );
2732
+ console.warn(
2733
+ `[fillo] form sync needs attention (${notice.code}): ${notice.message}`
2734
+ );
2735
+ onError?.(notice);
2736
+ }
2737
+ return result.formId;
2738
+ } catch (error) {
2739
+ const failure = isFilloError3(error) ? error : new FilloError3(error instanceof Error ? error.message : String(error), 0);
2740
+ console.warn(
2741
+ `[fillo] form sync failed${failure.code ? ` (${failure.code})` : ""}: ${failure.message}`
2742
+ );
2743
+ onError?.(failure);
2744
+ throw failure;
2745
+ }
2746
+ } : void 0,
2747
+ [client, codeForm, onError, schema]
2748
+ );
2188
2749
  const api = useFilloController({
2189
2750
  ...options,
2190
2751
  client,
2191
2752
  form: schema,
2192
2753
  formId: syncedFormId ?? formId,
2754
+ resolveFormId,
2193
2755
  // Headless = your markup, no Fillo-rendered layout. Free like every embed
2194
2756
  // method; recorded per response so usage stays measurable.
2195
2757
  surface: "headless"
2196
2758
  });
2197
2759
  useDraftFlush(api);
2198
- return /* @__PURE__ */ jsx7(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx7(FilloStringsContext.Provider, { value: resolveStrings2(strings), children: /* @__PURE__ */ jsx7(FilloAppearanceContext.Provider, { value: appearance, children: /* @__PURE__ */ jsx7(FilloInstanceIdContext.Provider, { value: instanceId, children }) }) }) });
2760
+ const resolvedStrings = resolveStrings2(strings);
2761
+ const canonicalSyncError = syncError && !options.skipValidation ? syncError : null;
2762
+ if (canonicalSyncError && renderError) return /* @__PURE__ */ jsx8(Fragment3, { children: renderError(canonicalSyncError) });
2763
+ if (canonicalSyncError && !isDevEnv3()) return null;
2764
+ if (codeFormSyncing && !options.skipValidation && !isDevEnv3()) return null;
2765
+ const notPublished = Boolean(codeForm) && syncedStatus === "draft" && !options.skipValidation;
2766
+ if (notPublished && !isDevEnv3()) {
2767
+ const error = new FilloError3("This form isn't published yet.", 403, void 0, "form_not_published");
2768
+ return renderError ? /* @__PURE__ */ jsx8(Fragment3, { children: renderError(error) }) : null;
2769
+ }
2770
+ return /* @__PURE__ */ jsx8(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx8(FilloStringsContext.Provider, { value: resolvedStrings, children: /* @__PURE__ */ jsx8(FilloAppearanceContext.Provider, { value: appearance, children: /* @__PURE__ */ jsx8(FilloInstanceIdContext.Provider, { value: instanceId, children }) }) }) });
2771
+ }
2772
+ function isDevEnv3() {
2773
+ return typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
2199
2774
  }
2200
2775
 
2201
2776
  // src/index.ts