@usefillo/react 0.6.2 → 0.8.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,10 +1,12 @@
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 { useEffect as useEffect7, useId as useId2, useMemo as useMemo5, useRef as useRef6, 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,
@@ -15,6 +17,7 @@ import { resolveStrings, slotClass as slotClass3 } from "@usefillo/core";
15
17
  // src/appearance.tsx
16
18
  import { createContext, useContext } from "react";
17
19
  import {
20
+ DEFAULT_FIELD_STRINGS,
18
21
  DEFAULT_STRINGS,
19
22
  slotClass
20
23
  } from "@usefillo/core";
@@ -49,7 +52,10 @@ function useFieldSlots(field, error) {
49
52
  control: (base) => slotClass(base, appearance, { slot: "control", ...state })
50
53
  };
51
54
  }
52
- var FilloStringsContext = createContext(DEFAULT_STRINGS);
55
+ var FilloStringsContext = createContext({
56
+ ...DEFAULT_STRINGS,
57
+ ...DEFAULT_FIELD_STRINGS
58
+ });
53
59
  function useStrings() {
54
60
  return useContext(FilloStringsContext);
55
61
  }
@@ -120,13 +126,19 @@ function useFilloController(options) {
120
126
  skipValidation: options.skipValidation,
121
127
  surface: options.surface ?? "headless",
122
128
  // Wrapped so the engine (created once) always calls the latest resolver.
123
- resolveFormId: options.resolveFormId ? () => optsRef.current.resolveFormId() : void 0
129
+ resolveFormId: options.resolveFormId ? () => optsRef.current.resolveFormId() : void 0,
130
+ respondent: options.respondent
124
131
  });
125
132
  }
126
133
  const store = storeRef.current;
127
134
  useEffect(() => {
128
- store.setContext({ form: options.form, formId: options.formId, client: options.client });
129
- }, [store, options.form, options.formId, options.client]);
135
+ store.setContext({
136
+ form: options.form,
137
+ formId: options.formId,
138
+ client: options.client,
139
+ respondent: options.respondent
140
+ });
141
+ }, [store, options.form, options.formId, options.client, options.respondent]);
130
142
  useEffect(() => () => store.destroy(), [store]);
131
143
  const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
132
144
  return useMemo2(
@@ -139,20 +151,40 @@ function useFilloController(options) {
139
151
  setUploading: store.setUploading,
140
152
  next: store.next,
141
153
  back: store.back,
142
- submit: store.submit
154
+ submit: store.submit,
155
+ flushDraft: store.flushDraft,
156
+ resetDraft: store.resetDraft
143
157
  }),
144
158
  [store, state, options.form, options.formId, options.client]
145
159
  );
146
160
  }
161
+ function useDraftFlush(api) {
162
+ const enabled = Boolean(api.form.settings.saveProgress);
163
+ const flush = api.flushDraft;
164
+ useEffect(() => {
165
+ if (!enabled || typeof window === "undefined") return;
166
+ const onPageHide = () => flush();
167
+ const onVisibility = () => {
168
+ if (document.visibilityState === "hidden") flush();
169
+ };
170
+ window.addEventListener("pagehide", onPageHide);
171
+ document.addEventListener("visibilitychange", onVisibility);
172
+ return () => {
173
+ window.removeEventListener("pagehide", onPageHide);
174
+ document.removeEventListener("visibilitychange", onVisibility);
175
+ };
176
+ }, [enabled, flush]);
177
+ }
147
178
 
148
179
  // src/define.ts
149
180
  import {
150
181
  contentHash,
151
182
  FilloError,
152
183
  isCodeForm,
184
+ isFilloError,
153
185
  syncCodeForm
154
186
  } from "@usefillo/core";
155
- import { useEffect as useEffect2, useState } from "react";
187
+ import { useEffect as useEffect2, useRef as useRef2, useState } from "react";
156
188
  import { defineForm, isCodeForm as isCodeForm2, syncCodeForm as syncCodeForm2 } from "@usefillo/core";
157
189
  var noticedSync = /* @__PURE__ */ new Set();
158
190
  function noticeSync(handle, kind, message) {
@@ -161,39 +193,156 @@ function noticeSync(handle, kind, message) {
161
193
  noticedSync.add(key);
162
194
  console.info(message);
163
195
  }
164
- function useCodeFormSync(form, client, onError) {
196
+ function useCodeFormSync(form, client, onError, options) {
165
197
  const codeForm = isCodeForm(form) ? form : null;
166
- const [synced, setSynced] = useState({ formId: null, status: null });
198
+ const [synced, setSynced] = useState({
199
+ key: null,
200
+ client: void 0,
201
+ settled: false,
202
+ formId: null,
203
+ status: null,
204
+ error: null,
205
+ noticeError: null,
206
+ staged: false
207
+ });
167
208
  const hasKey = Boolean(client?.key);
209
+ const allowUnsynced = Boolean(options?.allowUnsynced);
168
210
  const handle = codeForm?.id;
169
211
  const contentKey = codeForm ? contentHash(JSON.stringify(codeForm.schema) + JSON.stringify(codeForm.theme ?? null)) : null;
212
+ const syncKey = codeForm ? client?.key ? `${client.baseUrl}|${client.key}|${codeForm.id}|${contentKey}` : allowUnsynced ? null : `${client?.baseUrl ?? ""}|<missing-key>|${codeForm.id}|${contentKey}` : null;
213
+ const onErrorRef = useRef2(onError);
214
+ onErrorRef.current = onError;
170
215
  useEffect2(() => {
171
- if (!codeForm || !client?.key) return;
216
+ if (!codeForm || !syncKey) return;
172
217
  let cancelled = false;
218
+ setSynced(
219
+ (previous) => previous.key === syncKey && previous.client === client ? {
220
+ ...previous,
221
+ settled: false,
222
+ formId: null,
223
+ status: null,
224
+ error: null,
225
+ noticeError: null,
226
+ staged: false,
227
+ resolvedSchema: void 0,
228
+ resolvedTheme: void 0
229
+ } : {
230
+ key: syncKey,
231
+ client,
232
+ settled: false,
233
+ formId: null,
234
+ status: null,
235
+ error: null,
236
+ noticeError: null,
237
+ staged: false
238
+ }
239
+ );
240
+ if (!client?.key) {
241
+ const error = new FilloError(
242
+ 'Code forms need createClient({ key: "pk_\u2026" }) to resolve the live form. Pass a publishable key or a published formId.',
243
+ 401,
244
+ void 0,
245
+ "sync_key_required"
246
+ );
247
+ console.warn(`[fillo] form sync failed (${error.code}): ${error.message}`);
248
+ setSynced({
249
+ key: syncKey,
250
+ client,
251
+ settled: true,
252
+ formId: null,
253
+ status: null,
254
+ error,
255
+ noticeError: null,
256
+ staged: false
257
+ });
258
+ onErrorRef.current?.(error);
259
+ return;
260
+ }
173
261
  syncCodeForm(client, codeForm).then((r) => {
174
262
  if (cancelled) return;
175
- setSynced({ formId: r.formId, status: r.status ?? null });
263
+ if ((r.staged || r.syncError) && !r.resolvedSchema) {
264
+ throw new FilloError(
265
+ "Sync did not return the live schema needed to render safely. Publish changes before deploy, and update @usefillo/* or your API.",
266
+ 409,
267
+ void 0,
268
+ "sync_snapshot_required"
269
+ );
270
+ }
271
+ const noticeError = r.syncError ? new FilloError(r.syncError.message, 403, void 0, r.syncError.code) : null;
272
+ setSynced({
273
+ key: syncKey,
274
+ client,
275
+ settled: true,
276
+ formId: r.formId,
277
+ status: r.status ?? null,
278
+ branding: r.branding,
279
+ error: null,
280
+ noticeError,
281
+ staged: Boolean(r.staged),
282
+ resolvedSchema: r.resolvedSchema,
283
+ resolvedTheme: r.resolvedTheme
284
+ });
176
285
  const dashboardUrl = `${client.baseUrl || "https://fillo.so"}/forms/${r.formId}`;
177
286
  if (r.warning) console.warn(`[fillo] ${r.warning}`);
287
+ if (noticeError) {
288
+ console.warn(
289
+ `[fillo] form sync needs attention (${noticeError.code}): ${noticeError.message}`
290
+ );
291
+ onErrorRef.current?.(noticeError);
292
+ }
178
293
  if (r.staged) {
179
- noticeSync(codeForm.id, "staged", `[fillo] "${codeForm.id}": changes staged as a draft \u2014 publish them at ${dashboardUrl}`);
294
+ noticeSync(
295
+ codeForm.id,
296
+ "staged",
297
+ `[fillo] "${codeForm.id}": changes staged as a draft \u2014 the live version remains rendered until you publish them at ${dashboardUrl}`
298
+ );
180
299
  } else if (r.status === "draft") {
181
300
  noticeSync(codeForm.id, "draft", `[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published: ${dashboardUrl}`);
182
301
  }
183
302
  }).catch((err) => {
184
- const error = err instanceof FilloError ? err : new FilloError(String(err), 0);
185
- console.warn("[fillo] form sync failed:", error.message);
186
- if (!cancelled) onError?.(error);
303
+ if (cancelled) return;
304
+ const error = isFilloError(err) ? err : new FilloError(String(err), 0);
305
+ console.warn(
306
+ `[fillo] form sync failed${error.code ? ` (${error.code})` : ""}: ${error.message}`
307
+ );
308
+ setSynced({
309
+ key: syncKey,
310
+ client,
311
+ settled: true,
312
+ formId: null,
313
+ status: null,
314
+ error,
315
+ noticeError: null,
316
+ staged: false
317
+ });
318
+ onErrorRef.current?.(error);
187
319
  });
188
320
  return () => {
189
321
  cancelled = true;
190
322
  };
191
- }, [hasKey, handle, contentKey, client, onError]);
192
- return synced;
323
+ }, [hasKey, handle, contentKey, client, syncKey, allowUnsynced]);
324
+ return synced.key === syncKey && synced.client === client ? {
325
+ formId: synced.formId,
326
+ status: synced.status,
327
+ branding: synced.branding,
328
+ error: synced.error,
329
+ noticeError: synced.noticeError,
330
+ syncing: Boolean(syncKey) && !synced.settled,
331
+ staged: synced.staged,
332
+ resolvedSchema: synced.resolvedSchema,
333
+ resolvedTheme: synced.resolvedTheme
334
+ } : {
335
+ formId: null,
336
+ status: null,
337
+ error: null,
338
+ noticeError: null,
339
+ syncing: Boolean(syncKey),
340
+ staged: false
341
+ };
193
342
  }
194
343
 
195
344
  // src/fields.tsx
196
- import { isField, pipeBlock, shouldAutoSubmit, slotClass as slotClass2, visibleFields } from "@usefillo/core";
345
+ import { isField, pipeBlock, REQUIRED_FIELD_MESSAGE, shouldAutoSubmit, slotClass as slotClass2, visibleFields } from "@usefillo/core";
197
346
  import { memo, useEffect as useEffect6, useMemo as useMemo4, useState as useState5 } from "react";
198
347
 
199
348
  // src/phone.tsx
@@ -205,9 +354,10 @@ import {
205
354
  flagEmoji,
206
355
  formatNational,
207
356
  parsePhone,
357
+ positionPhonePopover,
208
358
  toE164
209
359
  } from "@usefillo/core";
210
- import { useEffect as useEffect3, useLayoutEffect, useMemo as useMemo3, useRef as useRef2, useState as useState2 } from "react";
360
+ import { useEffect as useEffect3, useLayoutEffect, useMemo as useMemo3, useRef as useRef3, useState as useState2 } from "react";
211
361
  import { jsx, jsxs } from "react/jsx-runtime";
212
362
  function localeCountry() {
213
363
  if (typeof navigator === "undefined") return void 0;
@@ -228,31 +378,6 @@ function nationalFor(stored, country) {
228
378
  const digits = digitsOnly(stored);
229
379
  return digits.startsWith(country.dialCode) ? digits.slice(country.dialCode.length) : digits;
230
380
  }
231
- var PHONE_POPOVER_VIEWPORT_GAP = 8;
232
- function positionPhonePopover(anchor, popover) {
233
- if (!anchor || !popover || typeof window === "undefined") return "below";
234
- const gap = PHONE_POPOVER_VIEWPORT_GAP;
235
- const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
236
- const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
237
- const anchorRect = anchor.getBoundingClientRect();
238
- const spaceBelow = Math.max(0, viewportHeight - anchorRect.bottom - gap);
239
- const spaceAbove = Math.max(0, anchorRect.top - gap);
240
- const placement = spaceBelow < popover.scrollHeight && spaceAbove > spaceBelow ? "above" : "below";
241
- const availableHeight = placement === "above" ? spaceAbove : spaceBelow;
242
- const maxHeight = Math.max(0, Math.min(availableHeight, viewportHeight - gap * 2));
243
- const maxWidth = Math.max(0, viewportWidth - gap * 2);
244
- popover.style.setProperty("--fillo-phone-popover-max-height", `${Math.floor(maxHeight)}px`);
245
- popover.style.setProperty("--fillo-phone-popover-max-width", `${Math.floor(maxWidth)}px`);
246
- popover.style.setProperty("--fillo-phone-popover-offset-x", "0px");
247
- const width = Math.min(popover.offsetWidth, maxWidth);
248
- let offsetX = 0;
249
- const rightOverflow = anchorRect.left + width - (viewportWidth - gap);
250
- if (rightOverflow > 0) offsetX -= rightOverflow;
251
- const leftOverflow = gap - (anchorRect.left + offsetX);
252
- if (leftOverflow > 0) offsetX += leftOverflow;
253
- popover.style.setProperty("--fillo-phone-popover-offset-x", `${Math.round(offsetX)}px`);
254
- return placement;
255
- }
256
381
  function PhoneField({ field, value, error, setValue, ids: providedIds }) {
257
382
  const ids = useFilloFieldIds(field.id, providedIds);
258
383
  const slots = useFieldSlots(field, error);
@@ -262,7 +387,12 @@ function PhoneField({ field, value, error, setValue, ids: providedIds }) {
262
387
  const [country, setCountry] = useState2(
263
388
  () => parsePhone(stored).country ?? countryByIso(schema.defaultCountry) ?? PHONE_COUNTRIES[0]
264
389
  );
265
- const touched = useRef2(false);
390
+ const touched = useRef3(false);
391
+ useEffect3(() => {
392
+ if (touched.current || !stored) return;
393
+ const restoredCountry = parsePhone(stored).country;
394
+ if (restoredCountry) setCountry(restoredCountry);
395
+ }, [stored]);
266
396
  useEffect3(() => {
267
397
  if (touched.current) return;
268
398
  if (parsePhone(stored).country || countryByIso(schema.defaultCountry)) return;
@@ -271,8 +401,8 @@ function PhoneField({ field, value, error, setValue, ids: providedIds }) {
271
401
  }, []);
272
402
  const national = nationalFor(stored, country);
273
403
  const display = formatNational(country, national);
274
- const inputRef = useRef2(null);
275
- const caretDigits = useRef2(null);
404
+ const inputRef = useRef3(null);
405
+ const caretDigits = useRef3(null);
276
406
  useLayoutEffect(() => {
277
407
  const el = inputRef.current;
278
408
  if (!el || caretDigits.current == null) return;
@@ -350,10 +480,10 @@ function CountrySelect({
350
480
  const [query, setQuery] = useState2("");
351
481
  const [active, setActive] = useState2(0);
352
482
  const [placement, setPlacement] = useState2("below");
353
- const rootRef = useRef2(null);
354
- const triggerRef = useRef2(null);
355
- const popoverRef = useRef2(null);
356
- const searchRef = useRef2(null);
483
+ const rootRef = useRef3(null);
484
+ const triggerRef = useRef3(null);
485
+ const popoverRef = useRef3(null);
486
+ const searchRef = useRef3(null);
357
487
  const listId = `${labelledBy}-countries`;
358
488
  const matches = useMemo3(() => {
359
489
  const q = query.trim().toLowerCase();
@@ -423,6 +553,7 @@ function CountrySelect({
423
553
  className: "fillo-phone-flag",
424
554
  "aria-haspopup": "listbox",
425
555
  "aria-expanded": open,
556
+ "aria-controls": open ? listId : void 0,
426
557
  "aria-label": `Country: ${country.name} (+${country.dialCode})`,
427
558
  onClick: () => setOpen((o) => !o),
428
559
  children: [
@@ -446,6 +577,7 @@ function CountrySelect({
446
577
  "aria-expanded": "true",
447
578
  "aria-controls": listId,
448
579
  "aria-autocomplete": "list",
580
+ "aria-label": "Search country or code",
449
581
  "aria-activedescendant": matches[active] ? `${listId}-opt-${active}` : void 0,
450
582
  placeholder: "Search country or code",
451
583
  value: query,
@@ -487,15 +619,15 @@ function CountrySelect({
487
619
  }
488
620
 
489
621
  // src/signature.tsx
490
- import { useEffect as useEffect4, useRef as useRef3, useState as useState3 } from "react";
622
+ import { useEffect as useEffect4, useRef as useRef4, useState as useState3 } from "react";
491
623
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
492
624
  function SignatureField({ field, value, error, setValue, ids: providedIds }) {
493
625
  const ids = useFilloFieldIds(field.id, providedIds);
494
626
  const slots = useFieldSlots(field, error);
495
627
  const strings = useStrings();
496
628
  const typedInputId = `${ids.inputId}-typed`;
497
- const canvasRef = useRef3(null);
498
- const drawing = useRef3(false);
629
+ const canvasRef = useRef4(null);
630
+ const drawing = useRef4(false);
499
631
  const [hasInk, setHasInk] = useState3(Boolean(value));
500
632
  const [typedName, setTypedName] = useState3("");
501
633
  useEffect4(() => {
@@ -512,12 +644,31 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
512
644
  ctx.lineCap = "round";
513
645
  ctx.lineJoin = "round";
514
646
  ctx.strokeStyle = getComputedStyle(canvas).color;
647
+ }, []);
648
+ useEffect4(() => {
649
+ const canvas = canvasRef.current;
650
+ const ctx = canvas?.getContext("2d");
651
+ if (!canvas || !ctx) return;
652
+ let cancelled = false;
653
+ const rect = canvas.getBoundingClientRect();
515
654
  if (typeof value === "string" && value.startsWith("data:image/")) {
655
+ setHasInk(true);
516
656
  const img = new Image();
517
- img.onload = () => ctx.drawImage(img, 0, 0, rect.width, rect.height);
657
+ img.onload = () => {
658
+ if (cancelled) return;
659
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
660
+ ctx.drawImage(img, 0, 0, rect.width, rect.height);
661
+ };
518
662
  img.src = value;
663
+ } else {
664
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
665
+ setHasInk(false);
666
+ setTypedName("");
519
667
  }
520
- }, []);
668
+ return () => {
669
+ cancelled = true;
670
+ };
671
+ }, [value]);
521
672
  function point(e) {
522
673
  const rect = e.currentTarget.getBoundingClientRect();
523
674
  return { x: e.clientX - rect.left, y: e.clientY - rect.top };
@@ -597,6 +748,7 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
597
748
  onPointerDown: start,
598
749
  onPointerMove: move,
599
750
  onPointerUp: end,
751
+ onPointerCancel: end,
600
752
  onPointerLeave: end
601
753
  }
602
754
  ),
@@ -632,21 +784,15 @@ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
632
784
  }
633
785
 
634
786
  // src/upload.tsx
635
- import { useEffect as useEffect5, useRef as useRef4, useState as useState4 } from "react";
787
+ import { useEffect as useEffect5, useRef as useRef5, useState as useState4 } from "react";
636
788
  import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
637
- function uploadStatus(inFlight, doneCount) {
789
+ function uploadStatus(inFlight, doneCount, strings) {
638
790
  const failed = inFlight.filter((f) => f.error).length;
639
791
  const active = inFlight.length - failed;
640
792
  const parts = [];
641
- if (active > 0) {
642
- parts.push(`Uploading ${active} ${active === 1 ? "file" : "files"}, please wait\u2026`);
643
- }
644
- if (failed > 0) {
645
- parts.push(`${failed} ${failed === 1 ? "upload" : "uploads"} failed.`);
646
- }
647
- if (active === 0 && doneCount > 0) {
648
- parts.push(`${doneCount} ${doneCount === 1 ? "file" : "files"} uploaded.`);
649
- }
793
+ if (active > 0) parts.push(strings.filesUploading(active));
794
+ if (failed > 0) parts.push(strings.uploadsFailed(failed));
795
+ if (active === 0 && doneCount > 0) parts.push(strings.filesUploaded(doneCount));
650
796
  return parts.join(" ");
651
797
  }
652
798
  function formatBytes(bytes) {
@@ -660,41 +806,49 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
660
806
  const slots = useFieldSlots(field, error);
661
807
  const strings = useStrings();
662
808
  const schema = field;
663
- const inputRef = useRef4(null);
809
+ const inputRef = useRef5(null);
664
810
  const [inFlight, setInFlight] = useState4([]);
665
811
  const [dragOver, setDragOver] = useState4(false);
666
- const mountedRef = useRef4(true);
667
- const controllers = useRef4(/* @__PURE__ */ new Map());
668
- const inFlightCount = useRef4(0);
669
- useEffect5(
670
- () => () => {
812
+ const mountedRef = useRef5(true);
813
+ const controllers = useRef5(/* @__PURE__ */ new Map());
814
+ const inFlightCount = useRef5(0);
815
+ const setUploading = api.setUploading;
816
+ const fieldId = field.id;
817
+ useEffect5(() => {
818
+ mountedRef.current = true;
819
+ return () => {
671
820
  mountedRef.current = false;
672
821
  controllers.current.forEach((c) => c.abort());
673
- },
674
- []
675
- );
822
+ if (inFlightCount.current > 0) setUploading(fieldId, false);
823
+ };
824
+ }, [fieldId, setUploading]);
676
825
  const files = Array.isArray(value) ? value : [];
826
+ const filesRef = useRef5(files);
827
+ filesRef.current = files;
677
828
  const maxFiles = schema.maxFiles ?? 1;
678
- const maxBytes = (schema.maxFileSizeMb ?? 500) * 1024 * 1024;
829
+ const maxMb = schema.maxFileSizeMb ?? 500;
830
+ const maxBytes = maxMb * 1024 * 1024;
679
831
  const canUpload = Boolean(api.client && api.formId);
680
- const remaining = maxFiles - files.length - inFlight.length;
832
+ const activeInFlight = inFlight.filter((f) => !f.error).length;
833
+ const remaining = maxFiles - files.length - activeInFlight;
681
834
  async function startUpload(file) {
835
+ if (maxFiles - filesRef.current.length - inFlightCount.current <= 0) return;
682
836
  const key = `${file.name}-${file.size}-${Math.floor(performance.now())}`;
683
837
  if (file.size > maxBytes) {
684
838
  setInFlight((prev) => [
685
839
  ...prev,
686
- { key, name: file.name, size: file.size, fraction: 0, error: `Larger than ${schema.maxFileSizeMb ?? 500} MB limit` }
840
+ { key, name: file.name, size: file.size, fraction: 0, file, tooLarge: true, error: strings.fileTooLarge(maxMb) }
687
841
  ]);
688
842
  return;
689
843
  }
690
- setInFlight((prev) => [...prev, { key, name: file.name, size: file.size, fraction: 0 }]);
844
+ setInFlight((prev) => [...prev, { key, name: file.name, size: file.size, fraction: 0, file }]);
691
845
  inFlightCount.current += 1;
692
- api.setUploading(field.id, true);
846
+ setUploading(fieldId, true);
693
847
  const controller = new AbortController();
694
848
  controllers.current.set(key, controller);
695
849
  try {
696
850
  const uploaded = await api.client.uploadFile(api.formId, file, {
697
- fieldId: field.id,
851
+ fieldId,
698
852
  signal: controller.signal,
699
853
  onProgress: ({ fraction }) => {
700
854
  if (mountedRef.current) {
@@ -704,11 +858,12 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
704
858
  });
705
859
  if (!mountedRef.current) return;
706
860
  setInFlight((prev) => prev.filter((f) => f.key !== key));
707
- const current = Array.isArray(api.data[field.id]) ? api.data[field.id] : [];
708
- setValue([...current, uploaded]);
861
+ const next = [...filesRef.current, uploaded].slice(0, maxFiles);
862
+ filesRef.current = next;
863
+ setValue(next);
709
864
  } catch (err) {
710
865
  if (mountedRef.current) {
711
- const message = err instanceof Error && err.name === "FilloError" && err.message ? err.message : "Upload failed \u2014 try again";
866
+ const message = err instanceof Error && err.name === "FilloError" && err.message ? err.message : strings.uploadFailed;
712
867
  setInFlight(
713
868
  (prev) => prev.map((f) => f.key === key ? { ...f, error: message } : f)
714
869
  );
@@ -716,7 +871,7 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
716
871
  } finally {
717
872
  controllers.current.delete(key);
718
873
  inFlightCount.current -= 1;
719
- if (mountedRef.current && inFlightCount.current === 0) api.setUploading(field.id, false);
874
+ if (mountedRef.current && inFlightCount.current === 0) setUploading(fieldId, false);
720
875
  }
721
876
  }
722
877
  function cancelUpload(key) {
@@ -725,11 +880,14 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
725
880
  }
726
881
  function handleFiles(list) {
727
882
  if (!list) return;
728
- Array.from(list).slice(0, Math.max(0, remaining)).forEach((file) => void startUpload(file));
883
+ const currentRoom = Math.max(0, maxFiles - filesRef.current.length - inFlightCount.current);
884
+ Array.from(list).slice(0, currentRoom).forEach((file) => void startUpload(file));
729
885
  if (inputRef.current) inputRef.current.value = "";
730
886
  }
731
887
  function removeFile(fileId) {
732
- setValue(files.filter((f) => f.fileId !== fileId));
888
+ const next = filesRef.current.filter((f) => f.fileId !== fileId);
889
+ filesRef.current = next;
890
+ setValue(next);
733
891
  }
734
892
  const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ") || void 0;
735
893
  return /* @__PURE__ */ jsxs3("div", { ...slots.wrapperProps(`fillo-field fillo-field--file_upload${error ? " fillo-field--error" : ""}`), children: [
@@ -757,12 +915,16 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
757
915
  onClick: () => canUpload && inputRef.current?.click(),
758
916
  role: "button",
759
917
  tabIndex: 0,
918
+ "aria-disabled": !canUpload || void 0,
760
919
  "aria-labelledby": ids.labelId,
761
920
  "aria-required": field.required ? true : void 0,
762
921
  "aria-invalid": error ? true : void 0,
763
922
  "aria-describedby": describedBy,
764
923
  onKeyDown: (e) => {
765
- if ((e.key === "Enter" || e.key === " ") && canUpload) inputRef.current?.click();
924
+ if (e.key === "Enter" || e.key === " ") {
925
+ e.preventDefault();
926
+ if (canUpload) inputRef.current?.click();
927
+ }
766
928
  },
767
929
  children: [
768
930
  /* @__PURE__ */ jsx3(
@@ -772,23 +934,16 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
772
934
  id: ids.inputId,
773
935
  type: "file",
774
936
  hidden: true,
937
+ disabled: !canUpload,
775
938
  multiple: maxFiles > 1,
776
939
  accept: schema.accept?.join(","),
777
940
  onChange: (e) => canUpload && handleFiles(e.target.files)
778
941
  }
779
942
  ),
780
943
  canUpload ? /* @__PURE__ */ jsxs3(Fragment, { children: [
781
- /* @__PURE__ */ jsxs3("span", { className: "fillo-dropzone-title", children: [
782
- "Drop ",
783
- maxFiles > 1 ? "files" : "a file",
784
- " here or click to browse"
785
- ] }),
786
- /* @__PURE__ */ jsxs3("span", { className: "fillo-dropzone-hint", children: [
787
- "Up to ",
788
- schema.maxFileSizeMb ?? 500,
789
- " MB per file \xB7 resumable"
790
- ] })
791
- ] }) : /* @__PURE__ */ jsx3("span", { className: "fillo-dropzone-hint", children: "Uploads are disabled in preview" })
944
+ /* @__PURE__ */ jsx3("span", { className: "fillo-dropzone-title", children: strings.dropzoneTitle(maxFiles > 1) }),
945
+ /* @__PURE__ */ jsx3("span", { className: "fillo-dropzone-hint", children: strings.dropzoneHint(maxMb) })
946
+ ] }) : /* @__PURE__ */ jsx3("span", { className: "fillo-dropzone-hint", children: strings.uploadsDisabled })
792
947
  ]
793
948
  }
794
949
  ),
@@ -799,7 +954,7 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
799
954
  "aria-live": "polite",
800
955
  className: "fillo-sr-only",
801
956
  style: { position: "absolute", left: "-9999px", width: 1, height: 1, overflow: "hidden" },
802
- children: uploadStatus(inFlight, files.length)
957
+ children: uploadStatus(inFlight, files.length, strings)
803
958
  }
804
959
  ),
805
960
  (inFlight.length > 0 || files.length > 0) && /* @__PURE__ */ jsxs3("ul", { className: "fillo-files", children: [
@@ -832,6 +987,21 @@ function FileUploadField({ field, value, error, setValue, api, ids: providedIds
832
987
  /* @__PURE__ */ jsx3("span", { className: "fillo-file-name", children: f.name }),
833
988
  f.error ? /* @__PURE__ */ jsxs3(Fragment, { children: [
834
989
  /* @__PURE__ */ jsx3("span", { className: "fillo-file-meta", children: f.error }),
990
+ f.file && !f.tooLarge && remaining > 0 && /* @__PURE__ */ jsx3(
991
+ "button",
992
+ {
993
+ type: "button",
994
+ className: "fillo-file-retry",
995
+ "aria-label": `${strings.uploadRetry} ${f.name}`,
996
+ onClick: () => {
997
+ const retryFile = f.file;
998
+ if (!retryFile) return;
999
+ setInFlight((prev) => prev.filter((x) => x.key !== f.key));
1000
+ void startUpload(retryFile);
1001
+ },
1002
+ children: strings.uploadRetry
1003
+ }
1004
+ ),
835
1005
  /* @__PURE__ */ jsx3(
836
1006
  "button",
837
1007
  {
@@ -950,6 +1120,9 @@ function TextInput({ field, value, error, setValue, ids: providedIds }) {
950
1120
  type,
951
1121
  value: typeof value === "string" || typeof value === "number" ? String(value) : "",
952
1122
  placeholder: field.placeholder,
1123
+ maxLength: "maxLength" in field ? field.maxLength : void 0,
1124
+ min: field.kind === "number" ? field.min : void 0,
1125
+ max: field.kind === "number" ? field.max : void 0,
953
1126
  onChange: (e) => setValue(e.target.value),
954
1127
  ...fieldAria(field, error, ids)
955
1128
  }
@@ -966,6 +1139,7 @@ function LongText({ field, value, error, setValue, ids: providedIds }) {
966
1139
  rows: 4,
967
1140
  value: typeof value === "string" ? value : "",
968
1141
  placeholder: field.placeholder,
1142
+ maxLength: "maxLength" in field ? field.maxLength : void 0,
969
1143
  onChange: (e) => setValue(e.target.value),
970
1144
  ...fieldAria(field, error, ids)
971
1145
  }
@@ -1221,12 +1395,18 @@ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
1221
1395
  }
1222
1396
  ) });
1223
1397
  }
1224
- var OTHER_SENTINEL = "__fw_other__";
1398
+ function otherSentinel(field) {
1399
+ let value = "__fillo_other__";
1400
+ const ids = new Set(field.options.map((option) => option.id));
1401
+ while (ids.has(value)) value += "_";
1402
+ return value;
1403
+ }
1225
1404
  function Dropdown({ field, value, error, setValue, ids: providedIds }) {
1226
1405
  const ids = useFilloFieldIds(field.id, providedIds);
1227
1406
  const strings = useStrings();
1228
1407
  const choice = field;
1229
1408
  const options = useDisplayOptions(choice);
1409
+ const otherValue = otherSentinel(choice);
1230
1410
  const isOtherValue = typeof value === "string" && value !== "" && !choice.options.some((o) => o.id === value);
1231
1411
  const [otherOn, setOtherOn] = useState5(isOtherValue);
1232
1412
  const otherActive = isOtherValue || value === "" && otherOn;
@@ -1239,9 +1419,9 @@ function Dropdown({ field, value, error, setValue, ids: providedIds }) {
1239
1419
  id: ids.inputId,
1240
1420
  ...control,
1241
1421
  ...fieldAria(field, error, ids),
1242
- value: otherActive ? OTHER_SENTINEL : typeof value === "string" ? value : "",
1422
+ value: otherActive ? otherValue : typeof value === "string" ? value : "",
1243
1423
  onChange: (e) => {
1244
- if (e.target.value === OTHER_SENTINEL) {
1424
+ if (e.target.value === otherValue) {
1245
1425
  setOtherOn(true);
1246
1426
  setValue("");
1247
1427
  } else {
@@ -1252,7 +1432,7 @@ function Dropdown({ field, value, error, setValue, ids: providedIds }) {
1252
1432
  children: [
1253
1433
  /* @__PURE__ */ jsx4("option", { value: "", children: field.placeholder ?? strings.choosePlaceholder }),
1254
1434
  options.map((opt) => /* @__PURE__ */ jsx4("option", { value: opt.id, children: opt.label }, opt.id)),
1255
- choice.allowOther && /* @__PURE__ */ jsx4("option", { value: OTHER_SENTINEL, children: `${strings.other}\u2026` })
1435
+ choice.allowOther && /* @__PURE__ */ jsx4("option", { value: otherValue, children: `${strings.other}\u2026` })
1256
1436
  ]
1257
1437
  }
1258
1438
  ),
@@ -1276,17 +1456,15 @@ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
1276
1456
  const ids = useFilloFieldIds(field.id, providedIds);
1277
1457
  const strings = useStrings();
1278
1458
  const appearance = useFilloAppearance();
1459
+ const slots = useFieldSlots(field, error);
1279
1460
  const checkbox = field;
1280
1461
  if (checkbox.appearance === "toggle") {
1281
1462
  return /* @__PURE__ */ jsxs4(
1282
1463
  "div",
1283
1464
  {
1284
- className: `fillo-field fillo-field--checkbox fillo-field--toggle${error ? " fillo-field--error" : ""}`,
1285
- "data-fillo": "field",
1286
- "data-field": field.id,
1287
- "data-kind": field.kind,
1288
- "data-invalid": error ? "" : void 0,
1289
- "data-required": field.required ? "" : void 0,
1465
+ ...slots.wrapperProps(
1466
+ `fillo-field fillo-field--checkbox fillo-field--toggle${error ? " fillo-field--error" : ""}`
1467
+ ),
1290
1468
  children: [
1291
1469
  /* @__PURE__ */ jsxs4("label", { className: "fillo-toggle", "data-fillo": "option", "data-checked": value === true ? "" : void 0, children: [
1292
1470
  /* @__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: [
@@ -1308,8 +1486,8 @@ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
1308
1486
  ),
1309
1487
  /* @__PURE__ */ jsx4("span", { className: "fillo-toggle-track", "aria-hidden": "true", children: /* @__PURE__ */ jsx4("span", { className: "fillo-toggle-thumb" }) })
1310
1488
  ] }),
1311
- field.description && /* @__PURE__ */ jsx4("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
1312
- error && /* @__PURE__ */ jsx4("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
1489
+ field.description && /* @__PURE__ */ jsx4("p", { className: slots.description, "data-fillo": "fieldDescription", id: ids.descriptionId, children: field.description }),
1490
+ error && /* @__PURE__ */ jsx4("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, role: "alert", children: error })
1313
1491
  ]
1314
1492
  }
1315
1493
  );
@@ -1317,12 +1495,9 @@ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
1317
1495
  return /* @__PURE__ */ jsxs4(
1318
1496
  "div",
1319
1497
  {
1320
- className: `fillo-field fillo-field--checkbox${error ? " fillo-field--error" : ""}`,
1321
- "data-fillo": "field",
1322
- "data-field": field.id,
1323
- "data-kind": field.kind,
1324
- "data-invalid": error ? "" : void 0,
1325
- "data-required": field.required ? "" : void 0,
1498
+ ...slots.wrapperProps(
1499
+ `fillo-field fillo-field--checkbox${error ? " fillo-field--error" : ""}`
1500
+ ),
1326
1501
  children: [
1327
1502
  /* @__PURE__ */ jsxs4("label", { className: "fillo-option", "data-fillo": "option", "data-checked": value === true ? "" : void 0, children: [
1328
1503
  /* @__PURE__ */ jsx4(
@@ -1341,8 +1516,8 @@ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
1341
1516
  !field.required && /* @__PURE__ */ jsx4("span", { className: "fillo-optional", children: strings.optional })
1342
1517
  ] })
1343
1518
  ] }),
1344
- field.description && /* @__PURE__ */ jsx4("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
1345
- error && /* @__PURE__ */ jsx4("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
1519
+ field.description && /* @__PURE__ */ jsx4("p", { className: slots.description, "data-fillo": "fieldDescription", id: ids.descriptionId, children: field.description }),
1520
+ error && /* @__PURE__ */ jsx4("p", { className: slots.error, "data-fillo": "error", id: ids.errorId, role: "alert", children: error })
1346
1521
  ]
1347
1522
  }
1348
1523
  );
@@ -1488,73 +1663,106 @@ function Ranking({ field, value, error, setValue, ids: providedIds }) {
1488
1663
  [next[index], next[target]] = [next[target], next[index]];
1489
1664
  setValue(next);
1490
1665
  }
1491
- return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4("ol", { className: "fillo-ranking", children: order.map((id, index) => {
1492
- const option = ranking.options.find((o) => o.id === id);
1493
- if (!option) return null;
1494
- return /* @__PURE__ */ jsxs4("li", { className: "fillo-ranking-item", children: [
1495
- /* @__PURE__ */ jsx4("span", { className: "fillo-ranking-index", children: index + 1 }),
1496
- /* @__PURE__ */ jsx4("span", { className: "fillo-ranking-label", children: option.label }),
1497
- /* @__PURE__ */ jsxs4("span", { className: "fillo-ranking-controls", children: [
1498
- /* @__PURE__ */ jsx4(
1499
- "button",
1500
- {
1501
- type: "button",
1502
- className: "fillo-ranking-move",
1503
- "aria-label": `Move ${option.label} up`,
1504
- disabled: index === 0,
1505
- onClick: () => move(id, -1),
1506
- children: "\u2191"
1507
- }
1508
- ),
1509
- /* @__PURE__ */ jsx4(
1510
- "button",
1511
- {
1512
- type: "button",
1513
- className: "fillo-ranking-move",
1514
- "aria-label": `Move ${option.label} down`,
1515
- disabled: index === order.length - 1,
1516
- onClick: () => move(id, 1),
1517
- children: "\u2193"
1518
- }
1519
- )
1520
- ] })
1521
- ] }, id);
1522
- }) }) });
1666
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4(
1667
+ "div",
1668
+ {
1669
+ id: ids.inputId,
1670
+ role: "group",
1671
+ "aria-labelledby": ids.labelId,
1672
+ ...fieldAria(field, error, ids),
1673
+ children: /* @__PURE__ */ jsx4("ol", { className: "fillo-ranking", children: order.map((id, index) => {
1674
+ const option = ranking.options.find((o) => o.id === id);
1675
+ if (!option) return null;
1676
+ return /* @__PURE__ */ jsxs4("li", { className: "fillo-ranking-item", children: [
1677
+ /* @__PURE__ */ jsx4("span", { className: "fillo-ranking-index", children: index + 1 }),
1678
+ /* @__PURE__ */ jsx4("span", { className: "fillo-ranking-label", children: option.label }),
1679
+ /* @__PURE__ */ jsxs4("span", { className: "fillo-ranking-controls", children: [
1680
+ /* @__PURE__ */ jsx4(
1681
+ "button",
1682
+ {
1683
+ type: "button",
1684
+ className: "fillo-ranking-move",
1685
+ "aria-label": `Move ${option.label} up`,
1686
+ disabled: index === 0,
1687
+ onClick: () => move(id, -1),
1688
+ children: "\u2191"
1689
+ }
1690
+ ),
1691
+ /* @__PURE__ */ jsx4(
1692
+ "button",
1693
+ {
1694
+ type: "button",
1695
+ className: "fillo-ranking-move",
1696
+ "aria-label": `Move ${option.label} down`,
1697
+ disabled: index === order.length - 1,
1698
+ onClick: () => move(id, 1),
1699
+ children: "\u2193"
1700
+ }
1701
+ )
1702
+ ] })
1703
+ ] }, id);
1704
+ }) })
1705
+ }
1706
+ ) });
1523
1707
  }
1524
1708
  function Matrix({ field, value, error, setValue, ids: providedIds }) {
1525
1709
  const ids = useFilloFieldIds(field.id, providedIds);
1526
1710
  const matrix = field;
1527
1711
  const answers = value && typeof value === "object" && !Array.isArray(value) ? value : {};
1528
- return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4("div", { className: "fillo-matrix-wrap", children: /* @__PURE__ */ jsxs4("table", { className: "fillo-matrix", children: [
1529
- /* @__PURE__ */ jsx4("thead", { children: /* @__PURE__ */ jsxs4("tr", { children: [
1530
- /* @__PURE__ */ jsx4("th", {}),
1531
- matrix.columns.map((col) => /* @__PURE__ */ jsx4("th", { scope: "col", children: col.label }, col.id))
1532
- ] }) }),
1533
- /* @__PURE__ */ jsx4("tbody", { children: matrix.rows.map((row) => {
1534
- const rowLabelId = `${ids.inputId}-${row.id}`;
1535
- const answered = answers[row.id] !== void 0;
1536
- const rowInvalid = Boolean(error) && field.required && !answered;
1537
- return (
1538
- // Each row is its own radiogroup, labelled by the row question.
1539
- /* @__PURE__ */ jsxs4("tr", { role: "radiogroup", "aria-labelledby": rowLabelId, children: [
1540
- /* @__PURE__ */ jsx4("th", { scope: "row", id: rowLabelId, children: row.label }),
1541
- matrix.columns.map((col) => /* @__PURE__ */ jsx4("td", { children: /* @__PURE__ */ jsx4(
1542
- "input",
1543
- {
1544
- type: "radio",
1545
- className: "fillo-option-input",
1546
- name: `${ids.name}-${row.id}`,
1547
- "aria-label": `${row.label}: ${col.label}`,
1548
- "aria-required": field.required ? true : void 0,
1549
- "aria-invalid": rowInvalid ? true : void 0,
1550
- checked: answers[row.id] === col.id,
1551
- onChange: () => setValue({ ...answers, [row.id]: col.id })
1552
- }
1553
- ) }, col.id))
1554
- ] }, row.id)
1555
- );
1556
- }) })
1557
- ] }) }) });
1712
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4(
1713
+ "div",
1714
+ {
1715
+ id: ids.inputId,
1716
+ className: "fillo-matrix-wrap",
1717
+ role: "group",
1718
+ "aria-labelledby": ids.labelId,
1719
+ ...fieldAria(field, error, ids),
1720
+ children: /* @__PURE__ */ jsxs4("table", { className: "fillo-matrix", children: [
1721
+ /* @__PURE__ */ jsx4("thead", { children: /* @__PURE__ */ jsxs4("tr", { children: [
1722
+ /* @__PURE__ */ jsx4("th", {}),
1723
+ matrix.columns.map((col) => /* @__PURE__ */ jsx4("th", { scope: "col", children: col.label }, col.id))
1724
+ ] }) }),
1725
+ /* @__PURE__ */ jsx4("tbody", { children: matrix.rows.map((row) => {
1726
+ const rowLabelId = `${ids.inputId}-${row.id}`;
1727
+ const answered = answers[row.id] !== void 0;
1728
+ const rowInvalid = Boolean(error) && field.required && !answered;
1729
+ const radioId = (col) => `${ids.inputId}-${row.id}-${col.id}`;
1730
+ return (
1731
+ // The <tr> keeps its native row role so the grid's row/column
1732
+ // header association survives for screen readers. The row's
1733
+ // radiogroup lives on an inner element that aria-owns the radios
1734
+ // scattered across the cells (they can't be DOM-nested inside a
1735
+ // single element within a table row).
1736
+ /* @__PURE__ */ jsxs4("tr", { children: [
1737
+ /* @__PURE__ */ jsx4("th", { scope: "row", children: /* @__PURE__ */ jsx4(
1738
+ "div",
1739
+ {
1740
+ role: "radiogroup",
1741
+ "aria-labelledby": rowLabelId,
1742
+ "aria-owns": matrix.columns.map(radioId).join(" "),
1743
+ children: /* @__PURE__ */ jsx4("span", { id: rowLabelId, children: row.label })
1744
+ }
1745
+ ) }),
1746
+ matrix.columns.map((col) => /* @__PURE__ */ jsx4("td", { children: /* @__PURE__ */ jsx4(
1747
+ "input",
1748
+ {
1749
+ id: radioId(col),
1750
+ type: "radio",
1751
+ className: "fillo-option-input",
1752
+ name: `${ids.name}-${row.id}`,
1753
+ "aria-label": `${row.label}: ${col.label}`,
1754
+ "aria-required": field.required ? true : void 0,
1755
+ "aria-invalid": rowInvalid ? true : void 0,
1756
+ checked: answers[row.id] === col.id,
1757
+ onChange: () => setValue({ ...answers, [row.id]: col.id })
1758
+ }
1759
+ ) }, col.id))
1760
+ ] }, row.id)
1761
+ );
1762
+ }) })
1763
+ ] })
1764
+ }
1765
+ ) });
1558
1766
  }
1559
1767
  function Hidden() {
1560
1768
  return null;
@@ -1608,8 +1816,8 @@ function blockPropsEqual(prev, next) {
1608
1816
  const id = next.block.id;
1609
1817
  if (prev.api.data[id] !== next.api.data[id]) return false;
1610
1818
  if (prev.api.errors[id] !== next.api.errors[id]) return false;
1611
- if (next.block.kind === "custom") {
1612
- 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;
1819
+ if (next.block.kind === "custom" || isField(next.block) && Boolean(next.components?.[next.block.kind])) {
1820
+ return prev.api === next.api;
1613
1821
  }
1614
1822
  if ((blockHasPiping(prev.block) || blockHasPiping(next.block)) && prev.api.data !== next.api.data) {
1615
1823
  return false;
@@ -1624,6 +1832,7 @@ function blockPropsEqual(prev, next) {
1624
1832
  function BlockRendererInner({ block, api, components, customComponents }) {
1625
1833
  block = pipeBlock(block, api.data, api.form);
1626
1834
  const ids = useFilloFieldIds(block.id);
1835
+ const strings = useStrings();
1627
1836
  if (!isField(block)) return /* @__PURE__ */ jsx4(ContentRenderer, { block });
1628
1837
  let Component;
1629
1838
  if (block.kind === "custom") {
@@ -1640,12 +1849,14 @@ function BlockRendererInner({ block, api, components, customComponents }) {
1640
1849
  } else {
1641
1850
  Component = components?.[block.kind] ?? DEFAULT_COMPONENTS[block.kind];
1642
1851
  }
1852
+ const rawError = api.errors[block.id];
1853
+ const error = rawError === REQUIRED_FIELD_MESSAGE ? strings.required : rawError;
1643
1854
  return /* @__PURE__ */ jsx4(
1644
1855
  Component,
1645
1856
  {
1646
1857
  field: block,
1647
1858
  value: api.data[block.id],
1648
- error: api.errors[block.id],
1859
+ error,
1649
1860
  setValue: (v) => {
1650
1861
  api.setValue(block.id, v);
1651
1862
  const ctx = {
@@ -1724,7 +1935,9 @@ function badgeUrl(href) {
1724
1935
  function focusFirstInvalid(formEl) {
1725
1936
  if (!formEl || typeof window === "undefined") return;
1726
1937
  const run = () => {
1727
- const target = formEl.querySelector('[aria-invalid="true"]');
1938
+ const invalid = formEl.querySelector('[aria-invalid="true"]');
1939
+ const focusable = 'input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])';
1940
+ const target = invalid?.matches(focusable) ? invalid : invalid?.querySelector(focusable);
1728
1941
  target?.focus?.();
1729
1942
  };
1730
1943
  if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
@@ -1752,35 +1965,54 @@ function FilloForm(props) {
1752
1965
  () => props.client ?? (formId ? createClient() : void 0),
1753
1966
  [props.client, formId]
1754
1967
  );
1755
- const [fetched, setFetched] = useState6(null);
1756
- const [fetchedTheme, setFetchedTheme] = useState6(null);
1757
- const [loadError, setLoadError] = useState6(null);
1758
- const [closed, setClosed] = useState6(false);
1759
- const [fetchedBranding, setFetchedBranding] = useState6(null);
1968
+ const [loaded, setLoaded] = useState6(null);
1969
+ const [loadFailure, setLoadFailure] = useState6(null);
1760
1970
  const codeForm = isCodeForm2(form) ? form : null;
1761
- const inlineSchema = codeForm ? codeForm.schema : form;
1762
1971
  const onError = props.onError;
1763
- const { formId: syncedFormId, status: syncedStatus } = useCodeFormSync(form, client, onError);
1764
- const onErrorRef = useRef5(onError);
1972
+ const {
1973
+ formId: syncedFormId,
1974
+ status: syncedStatus,
1975
+ branding: syncedBranding,
1976
+ error: syncError,
1977
+ noticeError: syncNoticeError,
1978
+ syncing: codeFormSyncing,
1979
+ staged: codeChangesStaged,
1980
+ resolvedSchema,
1981
+ resolvedTheme
1982
+ } = useCodeFormSync(form, client, onError, {
1983
+ // An absent client is intentional render-only use. Builder previews and an
1984
+ // explicit canonical id also do not need publishable-key resolution.
1985
+ allowUnsynced: Boolean(!client || props.skipValidation || formId)
1986
+ });
1987
+ const renderResolvedSnapshot = Boolean(resolvedSchema && !isDevEnv());
1988
+ const inlineSchema = codeForm ? renderResolvedSnapshot ? resolvedSchema : codeForm.schema : form;
1989
+ const onErrorRef = useRef6(onError);
1765
1990
  useEffect7(() => {
1766
1991
  onErrorRef.current = onError;
1767
1992
  });
1768
1993
  const needsFetch = !inlineSchema && Boolean(client && formId);
1769
1994
  useEffect7(() => {
1770
- if (!needsFetch || !client || !formId) return;
1995
+ if (!needsFetch || !client || !formId) {
1996
+ setLoaded(null);
1997
+ setLoadFailure(null);
1998
+ return;
1999
+ }
1771
2000
  let cancelled = false;
1772
- setFetched(null);
1773
- setLoadError(null);
2001
+ setLoaded(null);
2002
+ setLoadFailure(null);
1774
2003
  client.getForm(formId).then((published) => {
1775
2004
  if (cancelled) return;
1776
- setFetched(published.schema);
1777
- setFetchedTheme(published.theme);
1778
- setClosed(Boolean(published.closed));
1779
- setFetchedBranding(published.branding ?? null);
2005
+ setLoaded({
2006
+ formId,
2007
+ schema: published.schema,
2008
+ theme: published.theme,
2009
+ closed: Boolean(published.closed),
2010
+ branding: published.branding ?? null
2011
+ });
1780
2012
  }).catch((err) => {
1781
2013
  if (cancelled) return;
1782
2014
  const error = err instanceof FilloError2 ? err : new FilloError2("This form could not be loaded.", 0);
1783
- setLoadError(error);
2015
+ setLoadFailure({ formId, error });
1784
2016
  onErrorRef.current?.(error);
1785
2017
  });
1786
2018
  return () => {
@@ -1788,10 +2020,13 @@ function FilloForm(props) {
1788
2020
  };
1789
2021
  }, [needsFetch, client, formId]);
1790
2022
  const strings = resolveStrings(props.strings);
1791
- const rawSchema = inlineSchema ?? fetched;
2023
+ const currentLoaded = needsFetch && loaded?.formId === formId ? loaded : null;
2024
+ const loadError = needsFetch && loadFailure && loadFailure.formId === formId ? loadFailure.error : null;
2025
+ const rawSchema = inlineSchema ?? currentLoaded?.schema ?? null;
1792
2026
  const normalized = useMemo5(() => rawSchema ? normalizeFormSchema(rawSchema) : null, [rawSchema]);
1793
2027
  const schema = normalized?.ok ? normalized.schema : null;
1794
- const theme = props.appearance?.theme ?? props.theme ?? codeForm?.theme ?? fetchedTheme ?? void 0;
2028
+ const resolvedCodeTheme = renderResolvedSnapshot ? resolvedTheme ?? void 0 : codeForm?.theme;
2029
+ const theme = props.appearance?.theme ?? props.theme ?? resolvedCodeTheme ?? currentLoaded?.theme ?? void 0;
1795
2030
  if (normalized && !normalized.ok) {
1796
2031
  const error = new FilloError2(`This form could not be rendered: ${normalized.error}`, 422);
1797
2032
  if (props.renderError) return /* @__PURE__ */ jsx5(Fragment2, { children: props.renderError(error) });
@@ -1804,16 +2039,34 @@ function FilloForm(props) {
1804
2039
  const message = loadError.status === 404 ? strings.loadFailedNotFound : loadError.status === 0 ? strings.loadFailedNetwork : strings.loadFailed;
1805
2040
  return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--error ${props.className ?? ""}`, children: message });
1806
2041
  }
1807
- if (closed) {
2042
+ if (currentLoaded?.closed) {
1808
2043
  return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--closed ${props.className ?? ""}`, ...themeProps(theme), children: /* @__PURE__ */ jsx5("p", { className: "fillo-closed", children: strings.closed }) });
1809
2044
  }
2045
+ const canonicalSyncError = codeForm && syncError && !props.skipValidation ? syncError : null;
2046
+ if (canonicalSyncError && props.renderError) {
2047
+ return /* @__PURE__ */ jsx5(Fragment2, { children: props.renderError(canonicalSyncError) });
2048
+ }
2049
+ if (canonicalSyncError && !isDevEnv()) {
2050
+ return /* @__PURE__ */ jsx5(
2051
+ "div",
2052
+ {
2053
+ className: `fillo-form fillo-form--error ${props.className ?? ""}`,
2054
+ role: "alert",
2055
+ "data-fillo": "root",
2056
+ "data-state": "error",
2057
+ ...themeProps(theme),
2058
+ children: strings.loadFailed
2059
+ }
2060
+ );
2061
+ }
1810
2062
  const notPublished = Boolean(codeForm) && syncedStatus === "draft" && !props.skipValidation;
1811
2063
  if (notPublished && !isDevEnv()) {
1812
2064
  const error = new FilloError2("This form isn't published yet.", 403);
1813
2065
  if (props.renderError) return /* @__PURE__ */ jsx5(Fragment2, { children: props.renderError(error) });
1814
2066
  return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--closed ${props.className ?? ""}`, ...themeProps(theme), children: /* @__PURE__ */ jsx5("p", { className: "fillo-closed", children: strings.notLive }) });
1815
2067
  }
1816
- if (!schema) {
2068
+ const awaitingCanonical = Boolean(codeForm && codeFormSyncing && !props.skipValidation);
2069
+ if (!isDevEnv() && awaitingCanonical || !schema) {
1817
2070
  return /* @__PURE__ */ jsxs5(
1818
2071
  "div",
1819
2072
  {
@@ -1828,7 +2081,8 @@ function FilloForm(props) {
1828
2081
  }
1829
2082
  );
1830
2083
  }
1831
- const poweredBy = fetchedBranding?.poweredBy ?? true;
2084
+ const branding = codeForm ? syncedBranding : currentLoaded?.branding;
2085
+ const poweredBy = branding?.poweredBy ?? true;
1832
2086
  return /* @__PURE__ */ jsx5(
1833
2087
  ResolvedForm,
1834
2088
  {
@@ -1838,9 +2092,11 @@ function FilloForm(props) {
1838
2092
  theme,
1839
2093
  formId: syncedFormId ?? formId,
1840
2094
  poweredBy,
1841
- brandLabel: fetchedBranding?.label,
1842
- brandHref: fetchedBranding?.href,
1843
- draftNotice: notPublished
2095
+ brandLabel: branding?.label,
2096
+ brandHref: branding?.href,
2097
+ draftNotice: notPublished,
2098
+ stagedNotice: codeChangesStaged,
2099
+ syncErrorNotice: syncError ?? syncNoticeError ?? void 0
1844
2100
  }
1845
2101
  );
1846
2102
  }
@@ -1864,18 +2120,85 @@ function DevDraftNotice() {
1864
2120
  " It renders here, but it won\u2019t accept responses until you publish it in your Fillo dashboard. (This notice only appears in development.)"
1865
2121
  ] });
1866
2122
  }
2123
+ function DevStagedNotice() {
2124
+ return /* @__PURE__ */ jsxs5("div", { className: "fillo-devwarning", role: "alert", children: [
2125
+ /* @__PURE__ */ jsx5("strong", { children: "Code changes are staged." }),
2126
+ " This local preview is not the live form yet. Publish the changes in Fillo before deploying them for respondents."
2127
+ ] });
2128
+ }
2129
+ function DevSyncErrorNotice({ error }) {
2130
+ return /* @__PURE__ */ jsxs5("div", { className: "fillo-devwarning", role: "alert", children: [
2131
+ /* @__PURE__ */ jsxs5("strong", { children: [
2132
+ "Form sync needs attention",
2133
+ error.code ? ` (${error.code})` : "",
2134
+ "."
2135
+ ] }),
2136
+ " ",
2137
+ error.message,
2138
+ " The safe form remains available for development."
2139
+ ] });
2140
+ }
1867
2141
  function ResolvedForm(props) {
1868
2142
  const { schema, theme } = props;
1869
2143
  const instanceId = useId2().replace(/:/g, "");
1870
- const hpRef = useRef5("");
2144
+ const hpRef = useRef6(null);
1871
2145
  const codeForm = isCodeForm2(props.form) ? props.form : null;
1872
2146
  const client = props.client;
1873
2147
  const resolveFormId = useMemo5(
1874
2148
  () => codeForm && client?.key ? (
1875
2149
  // bypassCache: a stale cached formId must not be trusted at submit time.
1876
- () => syncCodeForm2(client, codeForm, { bypassCache: true }).then((r) => r.formId)
2150
+ async () => {
2151
+ try {
2152
+ const result = await syncCodeForm2(client, codeForm, { bypassCache: true });
2153
+ if (result.status === "draft") {
2154
+ throw new FilloError2(
2155
+ "This form is no longer published. Submission was stopped before sending answers.",
2156
+ 403,
2157
+ void 0,
2158
+ "form_not_published"
2159
+ );
2160
+ }
2161
+ const authoritative = normalizeFormSchema(result.resolvedSchema ?? codeForm.schema);
2162
+ if (!authoritative.ok) {
2163
+ throw new FilloError2(
2164
+ `The canonical form schema was invalid: ${authoritative.error}`,
2165
+ 502,
2166
+ void 0,
2167
+ "invalid_sync_schema"
2168
+ );
2169
+ }
2170
+ if (!formSchemasEqual(schema, authoritative.schema)) {
2171
+ throw new FilloError2(
2172
+ "The live form changed after this page loaded. Submission stopped before incompatible answers were sent. Reload to use the published schema.",
2173
+ 409,
2174
+ void 0,
2175
+ "form_schema_changed"
2176
+ );
2177
+ }
2178
+ if (result.syncError) {
2179
+ const notice = new FilloError2(
2180
+ result.syncError.message,
2181
+ 403,
2182
+ void 0,
2183
+ result.syncError.code
2184
+ );
2185
+ console.warn(
2186
+ `[fillo] form sync needs attention (${notice.code}): ${notice.message}`
2187
+ );
2188
+ props.onError?.(notice);
2189
+ }
2190
+ return result.formId;
2191
+ } catch (error) {
2192
+ const failure = isFilloError2(error) ? error : new FilloError2(error instanceof Error ? error.message : String(error), 0);
2193
+ console.warn(
2194
+ `[fillo] form sync failed${failure.code ? ` (${failure.code})` : ""}: ${failure.message}`
2195
+ );
2196
+ props.onError?.(failure);
2197
+ throw failure;
2198
+ }
2199
+ }
1877
2200
  ) : void 0,
1878
- [codeForm, client]
2201
+ [codeForm, client, props.onError, schema]
1879
2202
  );
1880
2203
  const api = useFilloController({
1881
2204
  form: schema,
@@ -1885,26 +2208,29 @@ function ResolvedForm(props) {
1885
2208
  onChange: props.onChange,
1886
2209
  onSubmitted: props.onSubmitted,
1887
2210
  skipValidation: props.skipValidation,
1888
- getHoneypot: () => hpRef.current,
2211
+ getHoneypot: () => hpRef.current?.value ?? "",
1889
2212
  // Framed renderer: Fillo draws the layout (and badge), so the surface is
1890
2213
  // "default", never the bare-engine "headless".
1891
2214
  surface: "default",
1892
- resolveFormId
2215
+ resolveFormId,
2216
+ respondent: props.respondent
1893
2217
  });
2218
+ useDraftFlush(api);
1894
2219
  const themed = useMemo5(() => themeProps(theme), [theme]);
1895
2220
  const settings = schema.settings;
1896
2221
  const submitted = api.status === "submitted";
2222
+ const liveSubmit = submitted && !api.restoredSubmission;
1897
2223
  useEffect7(() => {
1898
- if (!submitted || !settings.redirectUrl || typeof window === "undefined") return;
2224
+ if (!liveSubmit || !settings.redirectUrl || typeof window === "undefined") return;
1899
2225
  const url = safeHttpUrl(settings.redirectUrl);
1900
2226
  if (url) window.location.assign(url);
1901
- }, [submitted, settings.redirectUrl]);
1902
- const successRef = useRef5(null);
2227
+ }, [liveSubmit, settings.redirectUrl]);
2228
+ const successRef = useRef6(null);
1903
2229
  useEffect7(() => {
1904
- if (submitted) successRef.current?.focus();
1905
- }, [submitted]);
1906
- const blocksRef = useRef5(null);
1907
- const prevPageIndex = useRef5(api.pageIndex);
2230
+ if (liveSubmit) successRef.current?.focus();
2231
+ }, [liveSubmit]);
2232
+ const blocksRef = useRef6(null);
2233
+ const prevPageIndex = useRef6(api.pageIndex);
1908
2234
  useEffect7(() => {
1909
2235
  if (prevPageIndex.current === api.pageIndex) return;
1910
2236
  prevPageIndex.current = api.pageIndex;
@@ -1937,7 +2263,7 @@ function ResolvedForm(props) {
1937
2263
  children: [
1938
2264
  /* @__PURE__ */ jsx5("div", { className: "fillo-success-mark", "aria-hidden": "true", children: "\u2713" }),
1939
2265
  /* @__PURE__ */ jsx5("h2", { className: "fillo-success-title", children: settings.successTitle ?? strings.successTitle }),
1940
- /* @__PURE__ */ jsx5("p", { className: "fillo-success-message", children: settings.successMessage ?? strings.successMessage })
2266
+ /* @__PURE__ */ jsx5("p", { className: "fillo-success-message", children: api.duplicateSubmission ? strings.alreadyAnswered : settings.successMessage ?? strings.successMessage })
1941
2267
  ]
1942
2268
  }
1943
2269
  ),
@@ -1977,13 +2303,37 @@ function ResolvedForm(props) {
1977
2303
  children: [
1978
2304
  !props.client && !props.skipValidation && isDevEnv() && /* @__PURE__ */ jsx5(DevNoClientNotice, {}),
1979
2305
  props.draftNotice && isDevEnv() && /* @__PURE__ */ jsx5(DevDraftNotice, {}),
2306
+ props.stagedNotice && isDevEnv() && /* @__PURE__ */ jsx5(DevStagedNotice, {}),
2307
+ props.syncErrorNotice && isDevEnv() && /* @__PURE__ */ jsx5(DevSyncErrorNotice, { error: props.syncErrorNotice }),
2308
+ (api.resumedDraft || api.editingPrevious) && /* @__PURE__ */ jsxs5(
2309
+ "div",
2310
+ {
2311
+ className: slotClass3("fillo-resume", appearance, { slot: "resume" }),
2312
+ "data-fillo": "resume",
2313
+ role: "status",
2314
+ children: [
2315
+ /* @__PURE__ */ jsx5("span", { className: "fillo-resume-text", children: api.editingPrevious ? strings.editNotice : strings.resumeNotice }),
2316
+ /* @__PURE__ */ jsx5("button", { type: "button", className: "fillo-resume-clear", onClick: api.resetDraft, children: strings.resumeStartOver })
2317
+ ]
2318
+ }
2319
+ ),
2320
+ api.resumeLinkFailed && !api.resumedDraft && !api.editingPrevious && /* @__PURE__ */ jsx5(
2321
+ "div",
2322
+ {
2323
+ className: slotClass3("fillo-resume fillo-resume--expired", appearance, { slot: "resume" }),
2324
+ "data-fillo": "resume",
2325
+ role: "status",
2326
+ children: /* @__PURE__ */ jsx5("span", { className: "fillo-resume-text", children: strings.resumeLinkExpired })
2327
+ }
2328
+ ),
1980
2329
  multiPage && settings.showProgress !== false && /* @__PURE__ */ jsx5(
1981
2330
  "div",
1982
2331
  {
1983
2332
  className: slotClass3("fillo-progress-track", appearance, { slot: "progress" }),
1984
2333
  "data-fillo": "progress",
1985
2334
  role: "progressbar",
1986
- "aria-valuemin": 0,
2335
+ "aria-label": schema.title || "Form progress",
2336
+ "aria-valuemin": 1,
1987
2337
  "aria-valuemax": api.pageCount,
1988
2338
  "aria-valuenow": api.pageIndex + 1,
1989
2339
  children: /* @__PURE__ */ jsx5(
@@ -1997,7 +2347,7 @@ function ResolvedForm(props) {
1997
2347
  }
1998
2348
  ),
1999
2349
  api.pageIndex === 0 && props.showTitle !== false && /* @__PURE__ */ jsxs5("header", { className: slotClass3("fillo-header", appearance, { slot: "header" }), "data-fillo": "header", children: [
2000
- /* @__PURE__ */ jsx5("h1", { className: slotClass3("fillo-title", appearance, { slot: "title" }), "data-fillo": "title", children: schema.title }),
2350
+ schema.title && /* @__PURE__ */ jsx5("h1", { className: slotClass3("fillo-title", appearance, { slot: "title" }), "data-fillo": "title", children: schema.title }),
2001
2351
  schema.description && /* @__PURE__ */ jsx5(
2002
2352
  "p",
2003
2353
  {
@@ -2029,9 +2379,9 @@ function ResolvedForm(props) {
2029
2379
  "aria-hidden": "true",
2030
2380
  defaultValue: "",
2031
2381
  style: HP_STYLE,
2032
- ref: assertHpHidden,
2033
- onChange: (e) => {
2034
- hpRef.current = e.target.value;
2382
+ ref: (element) => {
2383
+ hpRef.current = element;
2384
+ assertHpHidden(element);
2035
2385
  }
2036
2386
  }
2037
2387
  ),
@@ -2065,7 +2415,7 @@ function ResolvedForm(props) {
2065
2415
  }
2066
2416
 
2067
2417
  // src/jsx.tsx
2068
- import { useRef as useRef6 } from "react";
2418
+ import { useRef as useRef7 } from "react";
2069
2419
  import {
2070
2420
  codeFormFromJsx,
2071
2421
  FilloJsxError,
@@ -2078,7 +2428,7 @@ function JsxForm(props) {
2078
2428
  const { id, title, description, settings, theme, client, children, ...rest } = props;
2079
2429
  const compiled = codeFormFromJsx({ id, title, description, settings, theme }, children);
2080
2430
  const json = JSON.stringify(compiled.schema) + JSON.stringify(compiled.theme ?? null);
2081
- const memo2 = useRef6(null);
2431
+ const memo2 = useRef7(null);
2082
2432
  if (memo2.current && memo2.current.json !== json && isDevEnv2()) {
2083
2433
  console.warn(
2084
2434
  `[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.`
@@ -2132,29 +2482,121 @@ var Fillo = {
2132
2482
 
2133
2483
  // src/provider.tsx
2134
2484
  import { useId as useId3, useMemo as useMemo6 } from "react";
2135
- import { FilloError as FilloError3, normalizeFormSchema as normalizeFormSchema2, resolveStrings as resolveStrings2 } from "@usefillo/core";
2136
- import { jsx as jsx7 } from "react/jsx-runtime";
2137
- function FilloProvider({ children, form, formId, client, appearance, strings, ...options }) {
2485
+ import {
2486
+ FilloError as FilloError3,
2487
+ formSchemasEqual as formSchemasEqual2,
2488
+ isFilloError as isFilloError3,
2489
+ normalizeFormSchema as normalizeFormSchema2,
2490
+ resolveStrings as resolveStrings2,
2491
+ syncCodeForm as syncCodeForm3
2492
+ } from "@usefillo/core";
2493
+ import { Fragment as Fragment3, jsx as jsx7 } from "react/jsx-runtime";
2494
+ function FilloProvider({
2495
+ children,
2496
+ form,
2497
+ formId,
2498
+ client,
2499
+ appearance,
2500
+ strings,
2501
+ onError,
2502
+ renderError,
2503
+ ...options
2504
+ }) {
2138
2505
  const instanceId = useId3().replace(/:/g, "");
2139
2506
  const codeForm = isCodeForm2(form) ? form : null;
2140
- const rawSchema = codeForm ? codeForm.schema : form;
2141
- const schemaKey = useMemo6(() => JSON.stringify(rawSchema), [rawSchema]);
2142
- const normalized = useMemo6(() => normalizeFormSchema2(rawSchema), [rawSchema, schemaKey]);
2507
+ const {
2508
+ formId: syncedFormId,
2509
+ status: syncedStatus,
2510
+ error: syncError,
2511
+ syncing: codeFormSyncing,
2512
+ resolvedSchema
2513
+ } = useCodeFormSync(form, client, onError, {
2514
+ allowUnsynced: Boolean(!client || formId || options.skipValidation)
2515
+ });
2516
+ const rawSchema = codeForm ? resolvedSchema && !isDevEnv3() ? resolvedSchema : codeForm.schema : form;
2517
+ const normalized = useMemo6(() => normalizeFormSchema2(rawSchema), [rawSchema]);
2143
2518
  if (!normalized.ok) {
2144
2519
  throw new FilloError3(`This form could not be rendered: ${normalized.error}`, 422);
2145
2520
  }
2146
2521
  const schema = normalized.schema;
2147
- const { formId: syncedFormId } = useCodeFormSync(form, client);
2522
+ const resolveFormId = useMemo6(
2523
+ () => codeForm && client?.key ? async () => {
2524
+ try {
2525
+ const result = await syncCodeForm3(client, codeForm, { bypassCache: true });
2526
+ if (result.status === "draft") {
2527
+ throw new FilloError3(
2528
+ "This form is no longer published. Submission was stopped before sending answers.",
2529
+ 403,
2530
+ void 0,
2531
+ "form_not_published"
2532
+ );
2533
+ }
2534
+ const authoritative = normalizeFormSchema2(result.resolvedSchema ?? codeForm.schema);
2535
+ if (!authoritative.ok) {
2536
+ throw new FilloError3(
2537
+ `The canonical form schema was invalid: ${authoritative.error}`,
2538
+ 502,
2539
+ void 0,
2540
+ "invalid_sync_schema"
2541
+ );
2542
+ }
2543
+ if (!formSchemasEqual2(schema, authoritative.schema)) {
2544
+ throw new FilloError3(
2545
+ "The live form changed after this page loaded. Submission stopped before incompatible answers were sent. Reload to use the published schema.",
2546
+ 409,
2547
+ void 0,
2548
+ "form_schema_changed"
2549
+ );
2550
+ }
2551
+ if (result.syncError) {
2552
+ const notice = new FilloError3(
2553
+ result.syncError.message,
2554
+ 403,
2555
+ void 0,
2556
+ result.syncError.code
2557
+ );
2558
+ console.warn(
2559
+ `[fillo] form sync needs attention (${notice.code}): ${notice.message}`
2560
+ );
2561
+ onError?.(notice);
2562
+ }
2563
+ return result.formId;
2564
+ } catch (error) {
2565
+ const failure = isFilloError3(error) ? error : new FilloError3(error instanceof Error ? error.message : String(error), 0);
2566
+ console.warn(
2567
+ `[fillo] form sync failed${failure.code ? ` (${failure.code})` : ""}: ${failure.message}`
2568
+ );
2569
+ onError?.(failure);
2570
+ throw failure;
2571
+ }
2572
+ } : void 0,
2573
+ [client, codeForm, onError, schema]
2574
+ );
2148
2575
  const api = useFilloController({
2149
2576
  ...options,
2150
2577
  client,
2151
2578
  form: schema,
2152
2579
  formId: syncedFormId ?? formId,
2580
+ resolveFormId,
2153
2581
  // Headless = your markup, no Fillo-rendered layout. Free like every embed
2154
2582
  // method; recorded per response so usage stays measurable.
2155
2583
  surface: "headless"
2156
2584
  });
2157
- 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 }) }) }) });
2585
+ useDraftFlush(api);
2586
+ const resolvedStrings = resolveStrings2(strings);
2587
+ const canonicalSyncError = syncError && !options.skipValidation ? syncError : null;
2588
+ if (canonicalSyncError && renderError) return /* @__PURE__ */ jsx7(Fragment3, { children: renderError(canonicalSyncError) });
2589
+ if (canonicalSyncError && !isDevEnv3()) return null;
2590
+ if (codeFormSyncing && !options.skipValidation && !isDevEnv3()) return null;
2591
+ const notPublished = Boolean(codeForm) && syncedStatus === "draft" && !options.skipValidation;
2592
+ if (notPublished && !isDevEnv3()) {
2593
+ const error = new FilloError3("This form isn't published yet.", 403, void 0, "form_not_published");
2594
+ return renderError ? /* @__PURE__ */ jsx7(Fragment3, { children: renderError(error) }) : null;
2595
+ }
2596
+ return /* @__PURE__ */ jsx7(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx7(FilloStringsContext.Provider, { value: resolvedStrings, children: /* @__PURE__ */ jsx7(FilloAppearanceContext.Provider, { value: appearance, children: /* @__PURE__ */ jsx7(FilloInstanceIdContext.Provider, { value: instanceId, children }) }) }) });
2597
+ }
2598
+ function isDevEnv3() {
2599
+ return typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
2158
2600
  }
2159
2601
 
2160
2602
  // src/index.ts