@usefillo/react 0.3.2 → 0.5.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,21 +1,52 @@
1
1
  "use client";
2
2
 
3
3
  // src/FilloForm.tsx
4
- import { useEffect as useEffect6, useMemo, useRef as useRef4, useState as useState5 } from "react";
5
- import { FilloError as FilloError2, createClient } from "@usefillo/core";
4
+ import { useEffect as useEffect7, useId as useId2, useMemo as useMemo4, useRef as useRef5, useState as useState6 } from "react";
5
+ import {
6
+ FilloError as FilloError2,
7
+ createClient,
8
+ isField as isField2,
9
+ normalizeFormSchema,
10
+ normalizeFormTheme
11
+ } from "@usefillo/core";
6
12
 
7
13
  // src/context.ts
8
14
  import { allFields } from "@usefillo/core";
9
- import { createContext, useContext } from "react";
15
+ import { createContext, useContext, useId, useMemo } from "react";
10
16
  var FilloContext = createContext(null);
17
+ var FilloInstanceIdContext = createContext(null);
18
+ function useFilloInstanceId() {
19
+ const instanceId = useContext(FilloInstanceIdContext);
20
+ const fallbackId = useId().replace(/:/g, "");
21
+ return instanceId ?? fallbackId;
22
+ }
23
+ function createFilloFieldIds(instanceId, fieldId) {
24
+ const base = `fillo-${instanceId}-${fieldId}`;
25
+ return {
26
+ inputId: base,
27
+ labelId: `${base}-label`,
28
+ descriptionId: `${base}-desc`,
29
+ errorId: `${base}-error`,
30
+ name: base
31
+ };
32
+ }
11
33
  function useFillo() {
12
34
  const api = useContext(FilloContext);
13
35
  if (!api) throw new Error("useFillo must be used inside <FilloForm> or <FilloProvider>");
14
36
  return api;
15
37
  }
38
+ function useFilloFieldIds(fieldId, ids) {
39
+ const instanceId = useFilloInstanceId();
40
+ return ids ?? createFilloFieldIds(instanceId, fieldId);
41
+ }
16
42
  function useField(fieldId) {
17
43
  const api = useFillo();
18
- const field = allFields(api.form).find((f) => f.id === fieldId);
44
+ const fieldsById = useMemo(() => {
45
+ const map = /* @__PURE__ */ new Map();
46
+ for (const f of allFields(api.form)) map.set(f.id, f);
47
+ return map;
48
+ }, [api.form]);
49
+ const field = fieldsById.get(fieldId);
19
50
  return {
20
51
  field,
21
52
  value: api.data[fieldId],
@@ -25,7 +56,7 @@ function useField(fieldId) {
25
56
  }
26
57
 
27
58
  // src/controller.ts
28
- import { useEffect, useRef, useSyncExternalStore } from "react";
59
+ import { useEffect, useMemo as useMemo2, useRef, useSyncExternalStore } from "react";
29
60
  import {
30
61
  createFormController
31
62
  } from "@usefillo/core";
@@ -52,17 +83,20 @@ function useFilloController(options) {
52
83
  }, [store, options.form, options.formId, options.client]);
53
84
  useEffect(() => () => store.destroy(), [store]);
54
85
  const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
55
- return {
56
- form: options.form,
57
- formId: options.formId,
58
- client: options.client,
59
- ...state,
60
- setValue: store.setValue,
61
- setUploading: store.setUploading,
62
- next: store.next,
63
- back: store.back,
64
- submit: store.submit
65
- };
86
+ return useMemo2(
87
+ () => ({
88
+ form: options.form,
89
+ formId: options.formId,
90
+ client: options.client,
91
+ ...state,
92
+ setValue: store.setValue,
93
+ setUploading: store.setUploading,
94
+ next: store.next,
95
+ back: store.back,
96
+ submit: store.submit
97
+ }),
98
+ [store, state, options.form, options.formId, options.client]
99
+ );
66
100
  }
67
101
 
68
102
  // src/define.ts
@@ -96,17 +130,247 @@ function useCodeFormSync(form, client, onError) {
96
130
  }
97
131
 
98
132
  // src/fields.tsx
99
- import { isField, pipeBlock } from "@usefillo/core";
100
- import { useEffect as useEffect5, useState as useState4 } from "react";
133
+ import { isField, pipeBlock, validateResponse } from "@usefillo/core";
134
+ import { memo, useEffect as useEffect6, useState as useState5 } from "react";
101
135
 
102
- // src/signature.tsx
103
- import { useEffect as useEffect3, useRef as useRef2, useState as useState2 } from "react";
136
+ // src/phone.tsx
137
+ import {
138
+ PHONE_COUNTRIES,
139
+ countryByIso,
140
+ digitsOnly,
141
+ flagEmoji,
142
+ formatNational,
143
+ parsePhone,
144
+ toE164
145
+ } from "@usefillo/core";
146
+ import { useEffect as useEffect3, useLayoutEffect, useMemo as useMemo3, useRef as useRef2, useState as useState2 } from "react";
104
147
  import { jsx, jsxs } from "react/jsx-runtime";
105
- function SignatureField({ field, value, error, setValue }) {
106
- const canvasRef = useRef2(null);
107
- const drawing = useRef2(false);
108
- const [hasInk, setHasInk] = useState2(Boolean(value));
148
+ function localeCountry() {
149
+ if (typeof navigator === "undefined") return void 0;
150
+ const tag = navigator.languages?.[0] || navigator.language || "";
151
+ const region = tag.split("-")[1];
152
+ return region && /^[A-Za-z]{2}$/.test(region) ? region.toUpperCase() : void 0;
153
+ }
154
+ function nationalFor(stored, country) {
155
+ const digits = digitsOnly(stored);
156
+ return digits.startsWith(country.dialCode) ? digits.slice(country.dialCode.length) : digits;
157
+ }
158
+ function PhoneField({ field, value, error, setValue, ids: providedIds }) {
159
+ const ids = useFilloFieldIds(field.id, providedIds);
160
+ const schema = field;
161
+ const stored = typeof value === "string" ? value : "";
162
+ const [country, setCountry] = useState2(
163
+ () => parsePhone(stored).country ?? countryByIso(schema.defaultCountry) ?? countryByIso(localeCountry()) ?? PHONE_COUNTRIES[0]
164
+ );
165
+ const national = nationalFor(stored, country);
166
+ const display = formatNational(country, national);
167
+ const inputRef = useRef2(null);
168
+ const caretDigits = useRef2(null);
169
+ useLayoutEffect(() => {
170
+ const el = inputRef.current;
171
+ if (!el || caretDigits.current == null) return;
172
+ const target = caretDigits.current;
173
+ caretDigits.current = null;
174
+ if (target === 0) return el.setSelectionRange(0, 0);
175
+ let seen = 0;
176
+ let pos = display.length;
177
+ for (let i = 0; i < display.length; i++) {
178
+ if (/\d/.test(display[i]) && ++seen === target) {
179
+ pos = i + 1;
180
+ break;
181
+ }
182
+ }
183
+ el.setSelectionRange(pos, pos);
184
+ });
185
+ const commit = (c, nat) => setValue(nat ? toE164(c, nat) : "");
186
+ function onInput(e) {
187
+ const el = e.target;
188
+ const raw = el.value;
189
+ const caret = el.selectionStart ?? raw.length;
190
+ caretDigits.current = digitsOnly(raw.slice(0, caret)).length;
191
+ if (raw.trimStart().startsWith("+")) {
192
+ const p = parsePhone(raw);
193
+ if (p.country) setCountry(p.country);
194
+ commit(p.country ?? country, p.country ? p.national : digitsOnly(raw));
195
+ return;
196
+ }
197
+ commit(country, digitsOnly(raw));
198
+ }
199
+ function pickCountry(c) {
200
+ setCountry(c);
201
+ commit(c, national);
202
+ inputRef.current?.focus();
203
+ }
204
+ const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ") || void 0;
205
+ return /* @__PURE__ */ jsxs("div", { className: `fillo-field fillo-field--phone${error ? " fillo-field--error" : ""}`, "data-field": field.id, children: [
206
+ /* @__PURE__ */ jsxs("label", { className: "fillo-label", id: ids.labelId, htmlFor: ids.inputId, children: [
207
+ field.label,
208
+ !field.required && /* @__PURE__ */ jsx("span", { className: "fillo-optional", children: " (optional)" })
209
+ ] }),
210
+ field.description && /* @__PURE__ */ jsx("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
211
+ /* @__PURE__ */ jsxs("div", { className: "fillo-phone", children: [
212
+ /* @__PURE__ */ jsx(CountrySelect, { country, onPick: pickCountry, labelledBy: ids.labelId }),
213
+ /* @__PURE__ */ jsx(
214
+ "input",
215
+ {
216
+ ref: inputRef,
217
+ id: ids.inputId,
218
+ className: "fillo-input fillo-phone-input",
219
+ type: "tel",
220
+ inputMode: "tel",
221
+ autoComplete: "tel-national",
222
+ value: display,
223
+ placeholder: formatNational(country, country.example),
224
+ onChange: onInput,
225
+ "aria-invalid": error ? true : void 0,
226
+ "aria-required": field.required ? true : void 0,
227
+ "aria-describedby": describedBy
228
+ }
229
+ )
230
+ ] }),
231
+ error && /* @__PURE__ */ jsx("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
232
+ ] });
233
+ }
234
+ function CountrySelect({
235
+ country,
236
+ onPick,
237
+ labelledBy
238
+ }) {
239
+ const [open, setOpen] = useState2(false);
240
+ const [query, setQuery] = useState2("");
241
+ const [active, setActive] = useState2(0);
242
+ const rootRef = useRef2(null);
243
+ const searchRef = useRef2(null);
244
+ const listId = `${labelledBy}-countries`;
245
+ const matches = useMemo3(() => {
246
+ const q = query.trim().toLowerCase();
247
+ if (!q) return PHONE_COUNTRIES;
248
+ const qDigits = q.replace(/[^\d]/g, "");
249
+ return PHONE_COUNTRIES.filter(
250
+ (c) => c.name.toLowerCase().includes(q) || c.iso2.toLowerCase() === q || qDigits.length > 0 && c.dialCode.startsWith(qDigits)
251
+ );
252
+ }, [query]);
253
+ useEffect3(() => {
254
+ if (!open) return;
255
+ setQuery("");
256
+ setActive(Math.max(0, PHONE_COUNTRIES.indexOf(country)));
257
+ const id = requestAnimationFrame(() => searchRef.current?.focus());
258
+ return () => cancelAnimationFrame(id);
259
+ }, [open, country]);
109
260
  useEffect3(() => {
261
+ if (!open) return;
262
+ const onDown = (e) => {
263
+ if (!rootRef.current?.contains(e.target)) setOpen(false);
264
+ };
265
+ document.addEventListener("mousedown", onDown);
266
+ return () => document.removeEventListener("mousedown", onDown);
267
+ }, [open]);
268
+ useEffect3(() => {
269
+ if (!open) return;
270
+ document.getElementById(`${listId}-opt-${active}`)?.scrollIntoView({ block: "nearest" });
271
+ }, [active, open, listId]);
272
+ function choose(c) {
273
+ if (!c) return;
274
+ onPick(c);
275
+ setOpen(false);
276
+ }
277
+ function onSearchKey(e) {
278
+ if (e.key === "ArrowDown") {
279
+ e.preventDefault();
280
+ setActive((i) => Math.min(i + 1, matches.length - 1));
281
+ } else if (e.key === "ArrowUp") {
282
+ e.preventDefault();
283
+ setActive((i) => Math.max(i - 1, 0));
284
+ } else if (e.key === "Enter") {
285
+ e.preventDefault();
286
+ choose(matches[active]);
287
+ } else if (e.key === "Escape") {
288
+ e.preventDefault();
289
+ setOpen(false);
290
+ }
291
+ }
292
+ return /* @__PURE__ */ jsxs("div", { className: "fillo-phone-country", ref: rootRef, children: [
293
+ /* @__PURE__ */ jsxs(
294
+ "button",
295
+ {
296
+ type: "button",
297
+ className: "fillo-phone-flag",
298
+ "aria-haspopup": "listbox",
299
+ "aria-expanded": open,
300
+ "aria-label": `Country: ${country.name} (+${country.dialCode})`,
301
+ onClick: () => setOpen((o) => !o),
302
+ children: [
303
+ /* @__PURE__ */ jsx("span", { className: "fillo-phone-flag-emoji", "aria-hidden": "true", children: flagEmoji(country.iso2) }),
304
+ /* @__PURE__ */ jsxs("span", { className: "fillo-phone-dial", children: [
305
+ "+",
306
+ country.dialCode
307
+ ] }),
308
+ /* @__PURE__ */ jsx("span", { className: "fillo-phone-caret", "aria-hidden": "true", children: "\u25BE" })
309
+ ]
310
+ }
311
+ ),
312
+ open && /* @__PURE__ */ jsxs("div", { className: "fillo-phone-popover", children: [
313
+ /* @__PURE__ */ jsx(
314
+ "input",
315
+ {
316
+ ref: searchRef,
317
+ className: "fillo-phone-search",
318
+ type: "text",
319
+ role: "combobox",
320
+ "aria-expanded": "true",
321
+ "aria-controls": listId,
322
+ "aria-autocomplete": "list",
323
+ "aria-activedescendant": matches[active] ? `${listId}-opt-${active}` : void 0,
324
+ placeholder: "Search country or code",
325
+ value: query,
326
+ onChange: (e) => {
327
+ setQuery(e.target.value);
328
+ setActive(0);
329
+ },
330
+ onKeyDown: onSearchKey
331
+ }
332
+ ),
333
+ /* @__PURE__ */ jsxs("ul", { className: "fillo-phone-list", role: "listbox", id: listId, "aria-label": "Country", children: [
334
+ matches.map((c, i) => /* @__PURE__ */ jsxs(
335
+ "li",
336
+ {
337
+ id: `${listId}-opt-${i}`,
338
+ role: "option",
339
+ "aria-selected": c.iso2 === country.iso2,
340
+ className: `fillo-phone-option${i === active ? " fillo-phone-option--active" : ""}`,
341
+ onMouseEnter: () => setActive(i),
342
+ onMouseDown: (e) => {
343
+ e.preventDefault();
344
+ choose(c);
345
+ },
346
+ children: [
347
+ /* @__PURE__ */ jsx("span", { className: "fillo-phone-flag-emoji", "aria-hidden": "true", children: flagEmoji(c.iso2) }),
348
+ /* @__PURE__ */ jsx("span", { className: "fillo-phone-option-name", children: c.name }),
349
+ /* @__PURE__ */ jsxs("span", { className: "fillo-phone-option-dial", children: [
350
+ "+",
351
+ c.dialCode
352
+ ] })
353
+ ]
354
+ },
355
+ c.iso2
356
+ )),
357
+ matches.length === 0 && /* @__PURE__ */ jsx("li", { className: "fillo-phone-empty", children: "No matches" })
358
+ ] })
359
+ ] })
360
+ ] });
361
+ }
362
+
363
+ // src/signature.tsx
364
+ import { useEffect as useEffect4, useRef as useRef3, useState as useState3 } from "react";
365
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
366
+ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
367
+ const ids = useFilloFieldIds(field.id, providedIds);
368
+ const typedInputId = `${ids.inputId}-typed`;
369
+ const canvasRef = useRef3(null);
370
+ const drawing = useRef3(false);
371
+ const [hasInk, setHasInk] = useState3(Boolean(value));
372
+ const [typedName, setTypedName] = useState3("");
373
+ useEffect4(() => {
110
374
  const canvas = canvasRef.current;
111
375
  if (!canvas) return;
112
376
  const scale = window.devicePixelRatio || 1;
@@ -133,12 +397,31 @@ function SignatureField({ field, value, error, setValue }) {
133
397
  function start(e) {
134
398
  drawing.current = true;
135
399
  e.currentTarget.setPointerCapture(e.pointerId);
400
+ if (typedName) setTypedName("");
136
401
  const ctx = e.currentTarget.getContext("2d");
137
402
  if (!ctx) return;
138
403
  const { x, y } = point(e);
139
404
  ctx.beginPath();
140
405
  ctx.moveTo(x, y);
141
406
  }
407
+ function drawTypedName(text) {
408
+ const canvas = canvasRef.current;
409
+ const ctx = canvas?.getContext("2d");
410
+ if (!canvas || !ctx) return;
411
+ const rect = canvas.getBoundingClientRect();
412
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
413
+ if (text.trim()) {
414
+ ctx.fillStyle = getComputedStyle(canvas).color;
415
+ ctx.font = `italic ${Math.min(40, rect.height * 0.45)}px "Segoe Script", "Brush Script MT", cursive`;
416
+ ctx.textBaseline = "middle";
417
+ ctx.fillText(text, 12, rect.height / 2);
418
+ setHasInk(true);
419
+ setValue(canvas.toDataURL("image/png"));
420
+ } else {
421
+ setHasInk(false);
422
+ setValue(null);
423
+ }
424
+ }
142
425
  function move(e) {
143
426
  if (!drawing.current) return;
144
427
  const ctx = e.currentTarget.getContext("2d");
@@ -159,55 +442,101 @@ function SignatureField({ field, value, error, setValue }) {
159
442
  if (!canvas || !ctx) return;
160
443
  ctx.clearRect(0, 0, canvas.width, canvas.height);
161
444
  setHasInk(false);
445
+ setTypedName("");
162
446
  setValue(null);
163
447
  }
164
- const describedBy = [field.description ? `fillo-${field.id}-desc` : null, error ? `fillo-${field.id}-error` : null].filter(Boolean).join(" ") || void 0;
165
- return /* @__PURE__ */ jsxs("div", { className: `fillo-field fillo-field--signature${error ? " fillo-field--error" : ""}`, children: [
166
- /* @__PURE__ */ jsxs("label", { className: "fillo-label", htmlFor: `fillo-${field.id}`, children: [
448
+ const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ") || void 0;
449
+ return /* @__PURE__ */ jsxs2("div", { className: `fillo-field fillo-field--signature${error ? " fillo-field--error" : ""}`, children: [
450
+ /* @__PURE__ */ jsxs2("span", { className: "fillo-label", id: ids.labelId, children: [
167
451
  field.label,
168
- !field.required && /* @__PURE__ */ jsx("span", { className: "fillo-optional", children: " (optional)" })
169
- ] }),
170
- field.description && /* @__PURE__ */ jsx("p", { className: "fillo-description", id: `fillo-${field.id}-desc`, children: field.description }),
171
- /* @__PURE__ */ jsxs("div", { className: "fillo-signature", children: [
172
- /* @__PURE__ */ jsx(
173
- "canvas",
174
- {
175
- ref: canvasRef,
176
- id: `fillo-${field.id}`,
177
- className: "fillo-signature-canvas",
178
- "aria-invalid": error ? true : void 0,
179
- "aria-describedby": describedBy,
180
- onPointerDown: start,
181
- onPointerMove: move,
182
- onPointerUp: end,
183
- onPointerLeave: end
184
- }
185
- ),
186
- hasInk && /* @__PURE__ */ jsx("button", { type: "button", className: "fillo-signature-clear", onClick: clear, children: "Clear" }),
187
- !hasInk && /* @__PURE__ */ jsx("span", { className: "fillo-signature-hint", children: "Sign here" })
452
+ !field.required && /* @__PURE__ */ jsx2("span", { className: "fillo-optional", children: " (optional)" })
188
453
  ] }),
189
- error && /* @__PURE__ */ jsx("p", { className: "fillo-error", id: `fillo-${field.id}-error`, role: "alert", children: error })
454
+ field.description && /* @__PURE__ */ jsx2("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
455
+ /* @__PURE__ */ jsxs2(
456
+ "div",
457
+ {
458
+ role: "group",
459
+ "aria-labelledby": ids.labelId,
460
+ "aria-describedby": describedBy,
461
+ children: [
462
+ /* @__PURE__ */ jsxs2("div", { className: "fillo-signature", children: [
463
+ /* @__PURE__ */ jsx2(
464
+ "canvas",
465
+ {
466
+ ref: canvasRef,
467
+ className: "fillo-signature-canvas",
468
+ "aria-hidden": "true",
469
+ onPointerDown: start,
470
+ onPointerMove: move,
471
+ onPointerUp: end,
472
+ onPointerLeave: end
473
+ }
474
+ ),
475
+ hasInk && /* @__PURE__ */ jsx2("button", { type: "button", className: "fillo-signature-clear", onClick: clear, children: "Clear" }),
476
+ !hasInk && /* @__PURE__ */ jsx2("span", { className: "fillo-signature-hint", "aria-hidden": "true", children: "Sign here" })
477
+ ] }),
478
+ /* @__PURE__ */ jsxs2("div", { className: "fillo-signature-type", children: [
479
+ /* @__PURE__ */ jsx2("label", { className: "fillo-signature-type-label", htmlFor: typedInputId, children: "Or type your full name to sign" }),
480
+ /* @__PURE__ */ jsx2(
481
+ "input",
482
+ {
483
+ id: typedInputId,
484
+ type: "text",
485
+ className: "fillo-input fillo-signature-type-input",
486
+ autoComplete: "name",
487
+ value: typedName,
488
+ required: field.required || void 0,
489
+ "aria-required": field.required || void 0,
490
+ "aria-invalid": error ? true : void 0,
491
+ "aria-describedby": describedBy,
492
+ onChange: (e) => {
493
+ setTypedName(e.target.value);
494
+ drawTypedName(e.target.value);
495
+ }
496
+ }
497
+ )
498
+ ] })
499
+ ]
500
+ }
501
+ ),
502
+ error && /* @__PURE__ */ jsx2("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
190
503
  ] });
191
504
  }
192
505
 
193
506
  // src/upload.tsx
194
- import { useEffect as useEffect4, useRef as useRef3, useState as useState3 } from "react";
195
- import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
507
+ import { useEffect as useEffect5, useRef as useRef4, useState as useState4 } from "react";
508
+ import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
509
+ function uploadStatus(inFlight, doneCount) {
510
+ const failed = inFlight.filter((f) => f.error).length;
511
+ const active = inFlight.length - failed;
512
+ const parts = [];
513
+ if (active > 0) {
514
+ parts.push(`Uploading ${active} ${active === 1 ? "file" : "files"}, please wait\u2026`);
515
+ }
516
+ if (failed > 0) {
517
+ parts.push(`${failed} ${failed === 1 ? "upload" : "uploads"} failed.`);
518
+ }
519
+ if (active === 0 && doneCount > 0) {
520
+ parts.push(`${doneCount} ${doneCount === 1 ? "file" : "files"} uploaded.`);
521
+ }
522
+ return parts.join(" ");
523
+ }
196
524
  function formatBytes(bytes) {
197
525
  if (bytes < 1024) return `${bytes} B`;
198
526
  if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(0)} KB`;
199
527
  if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
200
528
  return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
201
529
  }
202
- function FileUploadField({ field, value, error, setValue, api }) {
530
+ function FileUploadField({ field, value, error, setValue, api, ids: providedIds }) {
531
+ const ids = useFilloFieldIds(field.id, providedIds);
203
532
  const schema = field;
204
- const inputRef = useRef3(null);
205
- const [inFlight, setInFlight] = useState3([]);
206
- const [dragOver, setDragOver] = useState3(false);
207
- const mountedRef = useRef3(true);
208
- const controllers = useRef3(/* @__PURE__ */ new Map());
209
- const inFlightCount = useRef3(0);
210
- useEffect4(
533
+ const inputRef = useRef4(null);
534
+ const [inFlight, setInFlight] = useState4([]);
535
+ const [dragOver, setDragOver] = useState4(false);
536
+ const mountedRef = useRef4(true);
537
+ const controllers = useRef4(/* @__PURE__ */ new Map());
538
+ const inFlightCount = useRef4(0);
539
+ useEffect5(
211
540
  () => () => {
212
541
  mountedRef.current = false;
213
542
  controllers.current.forEach((c) => c.abort());
@@ -271,14 +600,14 @@ function FileUploadField({ field, value, error, setValue, api }) {
271
600
  function removeFile(fileId) {
272
601
  setValue(files.filter((f) => f.fileId !== fileId));
273
602
  }
274
- const describedBy = [field.description ? `fillo-${field.id}-desc` : null, error ? `fillo-${field.id}-error` : null].filter(Boolean).join(" ") || void 0;
275
- return /* @__PURE__ */ jsxs2("div", { className: `fillo-field fillo-field--file_upload${error ? " fillo-field--error" : ""}`, children: [
276
- /* @__PURE__ */ jsxs2("label", { className: "fillo-label", htmlFor: `fillo-${field.id}`, children: [
603
+ const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ") || void 0;
604
+ return /* @__PURE__ */ jsxs3("div", { className: `fillo-field fillo-field--file_upload${error ? " fillo-field--error" : ""}`, children: [
605
+ /* @__PURE__ */ jsxs3("label", { className: "fillo-label", htmlFor: ids.inputId, children: [
277
606
  field.label,
278
- !field.required && /* @__PURE__ */ jsx2("span", { className: "fillo-optional", children: " (optional)" })
607
+ !field.required && /* @__PURE__ */ jsx3("span", { className: "fillo-optional", children: " (optional)" })
279
608
  ] }),
280
- field.description && /* @__PURE__ */ jsx2("p", { className: "fillo-description", id: `fillo-${field.id}-desc`, children: field.description }),
281
- remaining > 0 && /* @__PURE__ */ jsxs2(
609
+ field.description && /* @__PURE__ */ jsx3("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
610
+ remaining > 0 && /* @__PURE__ */ jsxs3(
282
611
  "div",
283
612
  {
284
613
  className: `fillo-dropzone${dragOver ? " fillo-dropzone--over" : ""}${canUpload ? "" : " fillo-dropzone--disabled"}`,
@@ -299,11 +628,11 @@ function FileUploadField({ field, value, error, setValue, api }) {
299
628
  if ((e.key === "Enter" || e.key === " ") && canUpload) inputRef.current?.click();
300
629
  },
301
630
  children: [
302
- /* @__PURE__ */ jsx2(
631
+ /* @__PURE__ */ jsx3(
303
632
  "input",
304
633
  {
305
634
  ref: inputRef,
306
- id: `fillo-${field.id}`,
635
+ id: ids.inputId,
307
636
  type: "file",
308
637
  hidden: true,
309
638
  "aria-invalid": error ? true : void 0,
@@ -314,26 +643,27 @@ function FileUploadField({ field, value, error, setValue, api }) {
314
643
  onChange: (e) => handleFiles(e.target.files)
315
644
  }
316
645
  ),
317
- canUpload ? /* @__PURE__ */ jsxs2(Fragment, { children: [
318
- /* @__PURE__ */ jsxs2("span", { className: "fillo-dropzone-title", children: [
646
+ canUpload ? /* @__PURE__ */ jsxs3(Fragment, { children: [
647
+ /* @__PURE__ */ jsxs3("span", { className: "fillo-dropzone-title", children: [
319
648
  "Drop ",
320
649
  maxFiles > 1 ? "files" : "a file",
321
650
  " here or click to browse"
322
651
  ] }),
323
- /* @__PURE__ */ jsxs2("span", { className: "fillo-dropzone-hint", children: [
652
+ /* @__PURE__ */ jsxs3("span", { className: "fillo-dropzone-hint", children: [
324
653
  "Up to ",
325
654
  schema.maxFileSizeMb ?? 500,
326
655
  " MB per file \xB7 resumable"
327
656
  ] })
328
- ] }) : /* @__PURE__ */ jsx2("span", { className: "fillo-dropzone-hint", children: "Uploads are disabled in preview" })
657
+ ] }) : /* @__PURE__ */ jsx3("span", { className: "fillo-dropzone-hint", children: "Uploads are disabled in preview" })
329
658
  ]
330
659
  }
331
660
  ),
332
- (inFlight.length > 0 || files.length > 0) && /* @__PURE__ */ jsxs2("ul", { className: "fillo-files", children: [
333
- files.map((f) => /* @__PURE__ */ jsxs2("li", { className: "fillo-file fillo-file--done", children: [
334
- /* @__PURE__ */ jsx2("span", { className: "fillo-file-name", children: f.name }),
335
- /* @__PURE__ */ jsx2("span", { className: "fillo-file-meta", children: formatBytes(f.size) }),
336
- /* @__PURE__ */ jsx2(
661
+ /* @__PURE__ */ jsx3("div", { role: "status", "aria-live": "polite", className: "fillo-sr-only", children: uploadStatus(inFlight, files.length) }),
662
+ (inFlight.length > 0 || files.length > 0) && /* @__PURE__ */ jsxs3("ul", { className: "fillo-files", children: [
663
+ files.map((f) => /* @__PURE__ */ jsxs3("li", { className: "fillo-file fillo-file--done", children: [
664
+ /* @__PURE__ */ jsx3("span", { className: "fillo-file-name", children: f.name }),
665
+ /* @__PURE__ */ jsx3("span", { className: "fillo-file-meta", children: formatBytes(f.size) }),
666
+ /* @__PURE__ */ jsx3(
337
667
  "button",
338
668
  {
339
669
  type: "button",
@@ -344,50 +674,64 @@ function FileUploadField({ field, value, error, setValue, api }) {
344
674
  }
345
675
  )
346
676
  ] }, f.fileId)),
347
- inFlight.map((f) => /* @__PURE__ */ jsxs2("li", { className: `fillo-file${f.error ? " fillo-file--failed" : ""}`, children: [
348
- /* @__PURE__ */ jsx2("span", { className: "fillo-file-name", children: f.name }),
349
- f.error ? /* @__PURE__ */ jsxs2(Fragment, { children: [
350
- /* @__PURE__ */ jsx2("span", { className: "fillo-file-meta", children: f.error }),
351
- /* @__PURE__ */ jsx2(
352
- "button",
353
- {
354
- type: "button",
355
- className: "fillo-file-remove",
356
- "aria-label": "Dismiss",
357
- onClick: () => setInFlight((prev) => prev.filter((x) => x.key !== f.key)),
358
- children: "\xD7"
359
- }
360
- )
361
- ] }) : /* @__PURE__ */ jsxs2(Fragment, { children: [
362
- /* @__PURE__ */ jsxs2("span", { className: "fillo-file-meta", children: [
363
- Math.round(f.fraction * 100),
364
- "% of ",
365
- formatBytes(f.size)
366
- ] }),
367
- /* @__PURE__ */ jsx2("span", { className: "fillo-progress", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("span", { className: "fillo-progress-bar", style: { width: `${f.fraction * 100}%` } }) }),
368
- /* @__PURE__ */ jsx2(
369
- "button",
370
- {
371
- type: "button",
372
- className: "fillo-file-remove",
373
- "aria-label": `Cancel ${f.name}`,
374
- onClick: () => cancelUpload(f.key),
375
- children: "\xD7"
376
- }
377
- )
378
- ] })
379
- ] }, f.key))
677
+ inFlight.map((f) => /* @__PURE__ */ jsxs3(
678
+ "li",
679
+ {
680
+ className: `fillo-file${f.error ? " fillo-file--failed" : ""}`,
681
+ ...f.error ? {} : {
682
+ role: "progressbar",
683
+ "aria-label": `Uploading ${f.name}`,
684
+ "aria-valuemin": 0,
685
+ "aria-valuemax": 100,
686
+ "aria-valuenow": Math.round(f.fraction * 100)
687
+ },
688
+ children: [
689
+ /* @__PURE__ */ jsx3("span", { className: "fillo-file-name", children: f.name }),
690
+ f.error ? /* @__PURE__ */ jsxs3(Fragment, { children: [
691
+ /* @__PURE__ */ jsx3("span", { className: "fillo-file-meta", children: f.error }),
692
+ /* @__PURE__ */ jsx3(
693
+ "button",
694
+ {
695
+ type: "button",
696
+ className: "fillo-file-remove",
697
+ "aria-label": "Dismiss",
698
+ onClick: () => setInFlight((prev) => prev.filter((x) => x.key !== f.key)),
699
+ children: "\xD7"
700
+ }
701
+ )
702
+ ] }) : /* @__PURE__ */ jsxs3(Fragment, { children: [
703
+ /* @__PURE__ */ jsxs3("span", { className: "fillo-file-meta", children: [
704
+ Math.round(f.fraction * 100),
705
+ "% of ",
706
+ formatBytes(f.size)
707
+ ] }),
708
+ /* @__PURE__ */ jsx3("span", { className: "fillo-progress", "aria-hidden": "true", children: /* @__PURE__ */ jsx3("span", { className: "fillo-progress-bar", style: { width: `${f.fraction * 100}%` } }) }),
709
+ /* @__PURE__ */ jsx3(
710
+ "button",
711
+ {
712
+ type: "button",
713
+ className: "fillo-file-remove",
714
+ "aria-label": `Cancel ${f.name}`,
715
+ onClick: () => cancelUpload(f.key),
716
+ children: "\xD7"
717
+ }
718
+ )
719
+ ] })
720
+ ]
721
+ },
722
+ f.key
723
+ ))
380
724
  ] }),
381
- error && /* @__PURE__ */ jsx2("p", { className: "fillo-error", id: `fillo-${field.id}-error`, role: "alert", children: error })
725
+ error && /* @__PURE__ */ jsx3("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
382
726
  ] });
383
727
  }
384
728
 
385
729
  // src/fields.tsx
386
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
387
- function fieldAria(field, error) {
730
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
731
+ function fieldAria(field, error, ids) {
388
732
  const describedBy = [
389
- field.description ? `fillo-${field.id}-desc` : null,
390
- error ? `fillo-${field.id}-error` : null
733
+ field.description ? ids.descriptionId : null,
734
+ error ? ids.errorId : null
391
735
  ].filter(Boolean);
392
736
  return {
393
737
  "aria-invalid": error ? true : void 0,
@@ -398,58 +742,61 @@ function fieldAria(field, error) {
398
742
  function FieldShell({
399
743
  field,
400
744
  error,
745
+ ids,
401
746
  children
402
747
  }) {
403
- return /* @__PURE__ */ jsxs3(
748
+ return /* @__PURE__ */ jsxs4(
404
749
  "div",
405
750
  {
406
751
  className: `fillo-field fillo-field--${field.kind}${error ? " fillo-field--error" : ""}`,
407
752
  "data-field": field.id,
408
753
  children: [
409
- /* @__PURE__ */ jsxs3("label", { className: "fillo-label", id: `fillo-${field.id}-label`, htmlFor: `fillo-${field.id}`, children: [
754
+ /* @__PURE__ */ jsxs4("label", { className: "fillo-label", id: ids.labelId, htmlFor: ids.inputId, children: [
410
755
  field.label,
411
- !field.required && /* @__PURE__ */ jsx3("span", { className: "fillo-optional", children: " (optional)" })
756
+ !field.required && /* @__PURE__ */ jsx4("span", { className: "fillo-optional", children: " (optional)" })
412
757
  ] }),
413
- field.description && /* @__PURE__ */ jsx3("p", { className: "fillo-description", id: `fillo-${field.id}-desc`, children: field.description }),
758
+ field.description && /* @__PURE__ */ jsx4("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
414
759
  children,
415
- error && /* @__PURE__ */ jsx3("p", { className: "fillo-error", id: `fillo-${field.id}-error`, role: "alert", children: error })
760
+ error && /* @__PURE__ */ jsx4("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
416
761
  ]
417
762
  }
418
763
  );
419
764
  }
420
- function TextInput({ field, value, error, setValue }) {
421
- const type = field.kind === "email" ? "email" : field.kind === "url" ? "url" : field.kind === "phone" ? "tel" : field.kind === "number" ? "number" : field.kind === "date" ? "date" : "text";
422
- return /* @__PURE__ */ jsx3(FieldShell, { field, error, children: /* @__PURE__ */ jsx3(
765
+ function TextInput({ field, value, error, setValue, ids: providedIds }) {
766
+ const ids = useFilloFieldIds(field.id, providedIds);
767
+ const type = field.kind === "email" ? "email" : field.kind === "url" ? "url" : field.kind === "number" ? "number" : field.kind === "date" ? "date" : "text";
768
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4(
423
769
  "input",
424
770
  {
425
- id: `fillo-${field.id}`,
771
+ id: ids.inputId,
426
772
  className: "fillo-input",
427
773
  type,
428
774
  value: typeof value === "string" || typeof value === "number" ? String(value) : "",
429
775
  placeholder: field.placeholder,
430
776
  onChange: (e) => setValue(e.target.value),
431
- ...fieldAria(field, error)
777
+ ...fieldAria(field, error, ids)
432
778
  }
433
779
  ) });
434
780
  }
435
- function LongText({ field, value, error, setValue }) {
436
- return /* @__PURE__ */ jsx3(FieldShell, { field, error, children: /* @__PURE__ */ jsx3(
781
+ function LongText({ field, value, error, setValue, ids: providedIds }) {
782
+ const ids = useFilloFieldIds(field.id, providedIds);
783
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4(
437
784
  "textarea",
438
785
  {
439
- id: `fillo-${field.id}`,
786
+ id: ids.inputId,
440
787
  className: "fillo-input fillo-textarea",
441
788
  rows: 4,
442
789
  value: typeof value === "string" ? value : "",
443
790
  placeholder: field.placeholder,
444
791
  onChange: (e) => setValue(e.target.value),
445
- ...fieldAria(field, error)
792
+ ...fieldAria(field, error, ids)
446
793
  }
447
794
  ) });
448
795
  }
449
796
  function useDisplayOptions(choice) {
450
797
  const idsKey = choice.options.map((o) => o.id).join("\0");
451
- const [order, setOrder] = useState4(null);
452
- useEffect5(() => {
798
+ const [order, setOrder] = useState5(null);
799
+ useEffect6(() => {
453
800
  if (!choice.shuffleOptions) return;
454
801
  const ids = idsKey === "" ? [] : idsKey.split("\0");
455
802
  for (let i = ids.length - 1; i > 0; i--) {
@@ -463,32 +810,54 @@ function useDisplayOptions(choice) {
463
810
  const shuffled = order.flatMap((id) => byId.get(id) ?? []);
464
811
  return shuffled.length === choice.options.length ? shuffled : choice.options;
465
812
  }
466
- function SingleChoice({ field, value, error, setValue }) {
813
+ var OPTION_ICON_PATHS = {
814
+ thumbs_up: "M7 10v10M7 10l4.8-6.1a2 2 0 0 1 3.5 1.7L14.6 10h4.8a2.1 2.1 0 0 1 2 2.5l-1.1 5.4A3 3 0 0 1 17.4 20H7M3 10h4v10H3z",
815
+ thumbs_down: "M7 14V4M7 14l4.8 6.1a2 2 0 0 0 3.5-1.7L14.6 14h4.8a2.1 2.1 0 0 0 2-2.5l-1.1-5.4A3 3 0 0 0 17.4 4H7M3 4h4v10H3z"
816
+ };
817
+ function ChoiceIcon({ icon }) {
818
+ if (!icon) return null;
819
+ return /* @__PURE__ */ jsx4(
820
+ "svg",
821
+ {
822
+ className: "fillo-option-icon",
823
+ viewBox: "0 0 24 24",
824
+ fill: "none",
825
+ stroke: "currentColor",
826
+ strokeWidth: "1.8",
827
+ strokeLinecap: "round",
828
+ strokeLinejoin: "round",
829
+ "aria-hidden": "true",
830
+ children: /* @__PURE__ */ jsx4("path", { d: OPTION_ICON_PATHS[icon] })
831
+ }
832
+ );
833
+ }
834
+ function SingleChoice({ field, value, error, setValue, ids: providedIds }) {
835
+ const ids = useFilloFieldIds(field.id, providedIds);
467
836
  const choice = field;
468
837
  const options = useDisplayOptions(choice);
469
838
  const isOtherValue = typeof value === "string" && value !== "" && !choice.options.some((o) => o.id === value);
470
- const [otherOn, setOtherOn] = useState4(isOtherValue);
471
- const otherActive = otherOn || isOtherValue;
472
- return /* @__PURE__ */ jsx3(FieldShell, { field, error, children: /* @__PURE__ */ jsxs3(
839
+ const [otherOn, setOtherOn] = useState5(isOtherValue);
840
+ const otherActive = isOtherValue || value === "" && otherOn;
841
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsxs4(
473
842
  "div",
474
843
  {
475
844
  className: "fillo-options",
476
845
  role: "radiogroup",
477
- "aria-labelledby": `fillo-${field.id}-label`,
478
- ...fieldAria(field, error),
846
+ "aria-labelledby": ids.labelId,
847
+ ...fieldAria(field, error, ids),
479
848
  children: [
480
- options.map((opt) => /* @__PURE__ */ jsxs3(
849
+ options.map((opt) => /* @__PURE__ */ jsxs4(
481
850
  "label",
482
851
  {
483
852
  "data-option": opt.id,
484
- className: `fillo-option${value === opt.id ? " fillo-option--selected" : ""}`,
853
+ className: `fillo-option${opt.icon ? " fillo-option--has-icon" : ""}${value === opt.id ? " fillo-option--selected" : ""}`,
485
854
  children: [
486
- /* @__PURE__ */ jsx3(
855
+ /* @__PURE__ */ jsx4(
487
856
  "input",
488
857
  {
489
858
  type: "radio",
490
859
  className: "fillo-option-input",
491
- name: `fillo-${field.id}`,
860
+ name: ids.name,
492
861
  checked: value === opt.id,
493
862
  onChange: () => {
494
863
  setOtherOn(false);
@@ -496,66 +865,79 @@ function SingleChoice({ field, value, error, setValue }) {
496
865
  }
497
866
  }
498
867
  ),
499
- /* @__PURE__ */ jsx3("span", { className: "fillo-option-label", children: opt.label })
868
+ /* @__PURE__ */ jsx4(ChoiceIcon, { icon: opt.icon }),
869
+ /* @__PURE__ */ jsx4("span", { className: "fillo-option-label", children: opt.label })
500
870
  ]
501
871
  },
502
872
  opt.id
503
873
  )),
504
- choice.allowOther && /* @__PURE__ */ jsxs3("label", { className: `fillo-option${otherActive ? " fillo-option--selected" : ""}`, children: [
505
- /* @__PURE__ */ jsx3(
506
- "input",
507
- {
508
- type: "radio",
509
- className: "fillo-option-input",
510
- name: `fillo-${field.id}`,
511
- checked: otherActive,
512
- onChange: () => {
513
- setOtherOn(true);
514
- setValue("");
515
- }
516
- }
517
- ),
518
- /* @__PURE__ */ jsx3("span", { className: "fillo-option-label", children: "Other" }),
519
- otherActive && /* @__PURE__ */ jsx3(
520
- "input",
521
- {
522
- type: "text",
523
- className: "fillo-input fillo-other-input",
524
- placeholder: "Your answer",
525
- autoFocus: !isOtherValue,
526
- value: isOtherValue ? String(value) : "",
527
- onChange: (e) => setValue(e.target.value)
528
- }
529
- )
530
- ] })
874
+ choice.allowOther && // A <div>, not a <label>, so editing the free-text input below can't
875
+ // toggle the radio. The radio + "Other" text keep their own <label>.
876
+ /* @__PURE__ */ jsxs4(
877
+ "div",
878
+ {
879
+ className: `fillo-option fillo-option--other${otherActive ? " fillo-option--selected fillo-option--with-other" : ""}`,
880
+ children: [
881
+ /* @__PURE__ */ jsxs4("label", { className: "fillo-option-main", children: [
882
+ /* @__PURE__ */ jsx4(
883
+ "input",
884
+ {
885
+ type: "radio",
886
+ className: "fillo-option-input",
887
+ name: ids.name,
888
+ checked: otherActive,
889
+ onChange: () => {
890
+ setOtherOn(true);
891
+ setValue("");
892
+ }
893
+ }
894
+ ),
895
+ /* @__PURE__ */ jsx4("span", { className: "fillo-option-label", children: "Other" })
896
+ ] }),
897
+ otherActive && /* @__PURE__ */ jsx4(
898
+ "input",
899
+ {
900
+ type: "text",
901
+ className: "fillo-input fillo-other-input",
902
+ "aria-label": "Other \u2014 please specify",
903
+ placeholder: "Your answer",
904
+ autoFocus: !isOtherValue,
905
+ value: isOtherValue ? String(value) : "",
906
+ onChange: (e) => setValue(e.target.value)
907
+ }
908
+ )
909
+ ]
910
+ }
911
+ )
531
912
  ]
532
913
  }
533
914
  ) });
534
915
  }
535
- function MultiChoice({ field, value, error, setValue }) {
916
+ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
917
+ const ids = useFilloFieldIds(field.id, providedIds);
536
918
  const choice = field;
537
919
  const options = useDisplayOptions(choice);
538
- const ids = new Set(choice.options.map((o) => o.id));
920
+ const optionIds = new Set(choice.options.map((o) => o.id));
539
921
  const selected = Array.isArray(value) ? value : [];
540
- const otherText = selected.find((v) => !ids.has(v));
541
- const [otherOn, setOtherOn] = useState4(otherText !== void 0);
542
- const otherActive = otherOn || otherText !== void 0;
922
+ const otherText = selected.find((v) => !optionIds.has(v));
923
+ const [otherOn, setOtherOn] = useState5(otherText !== void 0);
924
+ const otherActive = otherText !== void 0 || otherOn;
543
925
  const toggle = (id) => setValue(selected.includes(id) ? selected.filter((v) => v !== id) : [...selected, id]);
544
- return /* @__PURE__ */ jsx3(FieldShell, { field, error, children: /* @__PURE__ */ jsxs3(
926
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsxs4(
545
927
  "div",
546
928
  {
547
929
  className: "fillo-options",
548
930
  role: "group",
549
- "aria-labelledby": `fillo-${field.id}-label`,
550
- ...fieldAria(field, error),
931
+ "aria-labelledby": ids.labelId,
932
+ ...fieldAria(field, error, ids),
551
933
  children: [
552
- options.map((opt) => /* @__PURE__ */ jsxs3(
934
+ options.map((opt) => /* @__PURE__ */ jsxs4(
553
935
  "label",
554
936
  {
555
937
  "data-option": opt.id,
556
- className: `fillo-option${selected.includes(opt.id) ? " fillo-option--selected" : ""}`,
938
+ className: `fillo-option${opt.icon ? " fillo-option--has-icon" : ""}${selected.includes(opt.id) ? " fillo-option--selected" : ""}`,
557
939
  children: [
558
- /* @__PURE__ */ jsx3(
940
+ /* @__PURE__ */ jsx4(
559
941
  "input",
560
942
  {
561
943
  type: "checkbox",
@@ -564,84 +946,101 @@ function MultiChoice({ field, value, error, setValue }) {
564
946
  onChange: () => toggle(opt.id)
565
947
  }
566
948
  ),
567
- /* @__PURE__ */ jsx3("span", { className: "fillo-option-label", children: opt.label })
949
+ /* @__PURE__ */ jsx4(ChoiceIcon, { icon: opt.icon }),
950
+ /* @__PURE__ */ jsx4("span", { className: "fillo-option-label", children: opt.label })
568
951
  ]
569
952
  },
570
953
  opt.id
571
954
  )),
572
- choice.allowOther && /* @__PURE__ */ jsxs3("label", { className: `fillo-option${otherActive ? " fillo-option--selected" : ""}`, children: [
573
- /* @__PURE__ */ jsx3(
574
- "input",
575
- {
576
- type: "checkbox",
577
- className: "fillo-option-input",
578
- checked: otherActive,
579
- onChange: () => {
580
- if (otherActive) {
581
- setOtherOn(false);
582
- setValue(selected.filter((v) => ids.has(v)));
583
- } else {
584
- setOtherOn(true);
955
+ choice.allowOther && // A <div>, not a <label>, so editing the free-text input below can't
956
+ // toggle the checkbox. The checkbox + "Other" text keep their own <label>.
957
+ /* @__PURE__ */ jsxs4(
958
+ "div",
959
+ {
960
+ className: `fillo-option fillo-option--other${otherActive ? " fillo-option--selected fillo-option--with-other" : ""}`,
961
+ children: [
962
+ /* @__PURE__ */ jsxs4("label", { className: "fillo-option-main", children: [
963
+ /* @__PURE__ */ jsx4(
964
+ "input",
965
+ {
966
+ type: "checkbox",
967
+ className: "fillo-option-input",
968
+ checked: otherActive,
969
+ onChange: () => {
970
+ if (otherActive) {
971
+ setOtherOn(false);
972
+ setValue(selected.filter((v) => optionIds.has(v)));
973
+ } else {
974
+ setOtherOn(true);
975
+ }
976
+ }
977
+ }
978
+ ),
979
+ /* @__PURE__ */ jsx4("span", { className: "fillo-option-label", children: "Other" })
980
+ ] }),
981
+ otherActive && /* @__PURE__ */ jsx4(
982
+ "input",
983
+ {
984
+ type: "text",
985
+ className: "fillo-input fillo-other-input",
986
+ "aria-label": "Other \u2014 please specify",
987
+ placeholder: "Your answer",
988
+ autoFocus: otherText === void 0,
989
+ value: otherText ?? "",
990
+ onChange: (e) => {
991
+ const rest = selected.filter((v) => optionIds.has(v));
992
+ setValue(e.target.value ? [...rest, e.target.value] : rest);
993
+ }
585
994
  }
586
- }
587
- }
588
- ),
589
- /* @__PURE__ */ jsx3("span", { className: "fillo-option-label", children: "Other" }),
590
- otherActive && /* @__PURE__ */ jsx3(
591
- "input",
592
- {
593
- type: "text",
594
- className: "fillo-input fillo-other-input",
595
- placeholder: "Your answer",
596
- autoFocus: otherText === void 0,
597
- value: otherText ?? "",
598
- onChange: (e) => {
599
- const rest = selected.filter((v) => ids.has(v));
600
- setValue(e.target.value ? [...rest, e.target.value] : rest);
601
- }
602
- }
603
- )
604
- ] })
995
+ )
996
+ ]
997
+ }
998
+ )
605
999
  ]
606
1000
  }
607
1001
  ) });
608
1002
  }
609
1003
  var OTHER_SENTINEL = "__fw_other__";
610
- function Dropdown({ field, value, error, setValue }) {
1004
+ function Dropdown({ field, value, error, setValue, ids: providedIds }) {
1005
+ const ids = useFilloFieldIds(field.id, providedIds);
611
1006
  const choice = field;
612
1007
  const options = useDisplayOptions(choice);
613
1008
  const isOtherValue = typeof value === "string" && value !== "" && !choice.options.some((o) => o.id === value);
614
- const [otherOn, setOtherOn] = useState4(isOtherValue);
615
- const otherActive = otherOn || isOtherValue;
616
- return /* @__PURE__ */ jsxs3(FieldShell, { field, error, children: [
617
- /* @__PURE__ */ jsxs3(
618
- "select",
619
- {
620
- id: `fillo-${field.id}`,
621
- className: "fillo-input fillo-select",
622
- ...fieldAria(field, error),
623
- value: otherActive ? OTHER_SENTINEL : typeof value === "string" ? value : "",
624
- onChange: (e) => {
625
- if (e.target.value === OTHER_SENTINEL) {
626
- setOtherOn(true);
627
- setValue("");
628
- } else {
629
- setOtherOn(false);
630
- setValue(e.target.value || null);
631
- }
632
- },
633
- children: [
634
- /* @__PURE__ */ jsx3("option", { value: "", children: field.placeholder ?? "Choose\u2026" }),
635
- options.map((opt) => /* @__PURE__ */ jsx3("option", { value: opt.id, children: opt.label }, opt.id)),
636
- choice.allowOther && /* @__PURE__ */ jsx3("option", { value: OTHER_SENTINEL, children: "Other\u2026" })
637
- ]
638
- }
639
- ),
640
- otherActive && /* @__PURE__ */ jsx3(
1009
+ const [otherOn, setOtherOn] = useState5(isOtherValue);
1010
+ const otherActive = isOtherValue || value === "" && otherOn;
1011
+ return /* @__PURE__ */ jsxs4(FieldShell, { field, error, ids, children: [
1012
+ /* @__PURE__ */ jsxs4("div", { className: "fillo-select-wrap", children: [
1013
+ /* @__PURE__ */ jsxs4(
1014
+ "select",
1015
+ {
1016
+ id: ids.inputId,
1017
+ className: "fillo-input fillo-select",
1018
+ ...fieldAria(field, error, ids),
1019
+ value: otherActive ? OTHER_SENTINEL : typeof value === "string" ? value : "",
1020
+ onChange: (e) => {
1021
+ if (e.target.value === OTHER_SENTINEL) {
1022
+ setOtherOn(true);
1023
+ setValue("");
1024
+ } else {
1025
+ setOtherOn(false);
1026
+ setValue(e.target.value || null);
1027
+ }
1028
+ },
1029
+ children: [
1030
+ /* @__PURE__ */ jsx4("option", { value: "", children: field.placeholder ?? "Choose\u2026" }),
1031
+ options.map((opt) => /* @__PURE__ */ jsx4("option", { value: opt.id, children: opt.label }, opt.id)),
1032
+ choice.allowOther && /* @__PURE__ */ jsx4("option", { value: OTHER_SENTINEL, children: "Other\u2026" })
1033
+ ]
1034
+ }
1035
+ ),
1036
+ /* @__PURE__ */ jsx4("span", { className: "fillo-select-icon", "aria-hidden": "true" })
1037
+ ] }),
1038
+ otherActive && /* @__PURE__ */ jsx4(
641
1039
  "input",
642
1040
  {
643
1041
  type: "text",
644
1042
  className: "fillo-input fillo-other-input fillo-other-input--block",
1043
+ "aria-label": "Other \u2014 please specify",
645
1044
  placeholder: "Your answer",
646
1045
  autoFocus: !isOtherValue,
647
1046
  value: isOtherValue ? String(value) : "",
@@ -650,76 +1049,180 @@ function Dropdown({ field, value, error, setValue }) {
650
1049
  )
651
1050
  ] });
652
1051
  }
653
- function Checkbox({ field, value, error, setValue }) {
654
- return /* @__PURE__ */ jsxs3(
1052
+ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
1053
+ const ids = useFilloFieldIds(field.id, providedIds);
1054
+ const checkbox = field;
1055
+ if (checkbox.appearance === "toggle") {
1056
+ return /* @__PURE__ */ jsxs4(
1057
+ "div",
1058
+ {
1059
+ className: `fillo-field fillo-field--checkbox fillo-field--toggle${error ? " fillo-field--error" : ""}`,
1060
+ "data-field": field.id,
1061
+ children: [
1062
+ /* @__PURE__ */ jsxs4("label", { className: "fillo-toggle", children: [
1063
+ /* @__PURE__ */ jsx4("span", { className: "fillo-toggle-copy", children: /* @__PURE__ */ jsxs4("span", { className: "fillo-option-label", children: [
1064
+ field.label,
1065
+ !field.required && /* @__PURE__ */ jsx4("span", { className: "fillo-optional", children: " (optional)" })
1066
+ ] }) }),
1067
+ /* @__PURE__ */ jsx4(
1068
+ "input",
1069
+ {
1070
+ id: ids.inputId,
1071
+ type: "checkbox",
1072
+ role: "switch",
1073
+ className: "fillo-toggle-input",
1074
+ checked: value === true,
1075
+ onChange: (e) => setValue(e.target.checked),
1076
+ "aria-checked": value === true,
1077
+ ...fieldAria(field, error, ids)
1078
+ }
1079
+ ),
1080
+ /* @__PURE__ */ jsx4("span", { className: "fillo-toggle-track", "aria-hidden": "true", children: /* @__PURE__ */ jsx4("span", { className: "fillo-toggle-thumb" }) })
1081
+ ] }),
1082
+ field.description && /* @__PURE__ */ jsx4("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
1083
+ error && /* @__PURE__ */ jsx4("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
1084
+ ]
1085
+ }
1086
+ );
1087
+ }
1088
+ return /* @__PURE__ */ jsxs4(
655
1089
  "div",
656
1090
  {
657
1091
  className: `fillo-field fillo-field--checkbox${error ? " fillo-field--error" : ""}`,
658
1092
  "data-field": field.id,
659
1093
  children: [
660
- /* @__PURE__ */ jsxs3("label", { className: "fillo-option", children: [
661
- /* @__PURE__ */ jsx3(
1094
+ /* @__PURE__ */ jsxs4("label", { className: "fillo-option", children: [
1095
+ /* @__PURE__ */ jsx4(
662
1096
  "input",
663
1097
  {
664
- id: `fillo-${field.id}`,
1098
+ id: ids.inputId,
665
1099
  type: "checkbox",
666
1100
  className: "fillo-option-input",
667
1101
  checked: value === true,
668
1102
  onChange: (e) => setValue(e.target.checked),
669
- ...fieldAria(field, error)
1103
+ ...fieldAria(field, error, ids)
670
1104
  }
671
1105
  ),
672
- /* @__PURE__ */ jsxs3("span", { className: "fillo-option-label", children: [
1106
+ /* @__PURE__ */ jsxs4("span", { className: "fillo-option-label", children: [
673
1107
  field.label,
674
- !field.required && /* @__PURE__ */ jsx3("span", { className: "fillo-optional", children: " (optional)" })
1108
+ !field.required && /* @__PURE__ */ jsx4("span", { className: "fillo-optional", children: " (optional)" })
675
1109
  ] })
676
1110
  ] }),
677
- field.description && /* @__PURE__ */ jsx3("p", { className: "fillo-description", id: `fillo-${field.id}-desc`, children: field.description }),
678
- error && /* @__PURE__ */ jsx3("p", { className: "fillo-error", id: `fillo-${field.id}-error`, role: "alert", children: error })
1111
+ field.description && /* @__PURE__ */ jsx4("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
1112
+ error && /* @__PURE__ */ jsx4("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
679
1113
  ]
680
1114
  }
681
1115
  );
682
1116
  }
683
- function Rating({ field, value, error, setValue }) {
1117
+ function radiogroupKeyDown(e, steps, current, setValue) {
1118
+ const forward = e.key === "ArrowRight" || e.key === "ArrowDown";
1119
+ const backward = e.key === "ArrowLeft" || e.key === "ArrowUp";
1120
+ if (!forward && !backward) return;
1121
+ e.preventDefault();
1122
+ const index = current == null ? -1 : steps.indexOf(current);
1123
+ let next;
1124
+ if (index === -1) {
1125
+ next = forward ? 0 : steps.length - 1;
1126
+ } else {
1127
+ next = forward ? index + 1 : index - 1;
1128
+ next = (next + steps.length) % steps.length;
1129
+ }
1130
+ const value = steps[next];
1131
+ if (value === void 0) return;
1132
+ setValue(value);
1133
+ const group = e.currentTarget;
1134
+ const buttons = group.querySelectorAll('[role="radio"]');
1135
+ buttons[next]?.focus();
1136
+ }
1137
+ function rovingIndex(steps, current) {
1138
+ if (current == null) return 0;
1139
+ const i = steps.indexOf(current);
1140
+ return i === -1 ? 0 : i;
1141
+ }
1142
+ function Rating({ field, value, error, setValue, ids: providedIds }) {
1143
+ const ids = useFilloFieldIds(field.id, providedIds);
684
1144
  const max = field.max ?? 5;
685
1145
  const current = typeof value === "number" ? value : 0;
686
- return /* @__PURE__ */ jsx3(FieldShell, { field, error, children: /* @__PURE__ */ jsx3("div", { className: "fillo-rating", role: "radiogroup", "aria-label": field.label, ...fieldAria(field, error), children: Array.from({ length: max }, (_, i) => i + 1).map((n) => /* @__PURE__ */ jsx3(
687
- "button",
1146
+ const steps = Array.from({ length: max }, (_, i) => i + 1);
1147
+ const selected = current > 0 ? current : null;
1148
+ const tabbable = rovingIndex(steps, selected);
1149
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4(
1150
+ "div",
688
1151
  {
689
- type: "button",
690
- className: `fillo-star${n <= current ? " fillo-star--active" : ""}`,
691
- "aria-label": `${n} of ${max}`,
692
- "aria-pressed": n === current,
693
- onClick: () => setValue(n === current ? null : n),
694
- children: "\u2605"
695
- },
696
- n
697
- )) }) });
1152
+ className: "fillo-rating",
1153
+ role: "radiogroup",
1154
+ "aria-labelledby": ids.labelId,
1155
+ ...fieldAria(field, error, ids),
1156
+ onKeyDown: (e) => radiogroupKeyDown(e, steps, selected, (n) => setValue(n)),
1157
+ children: steps.map((n, i) => /* @__PURE__ */ jsx4(
1158
+ "button",
1159
+ {
1160
+ type: "button",
1161
+ className: `fillo-star${n <= current ? " fillo-star--active" : ""}`,
1162
+ role: "radio",
1163
+ "aria-label": `${n} of ${max}`,
1164
+ "aria-checked": n === current,
1165
+ tabIndex: i === tabbable ? 0 : -1,
1166
+ onClick: () => setValue(n === current ? null : n),
1167
+ children: "\u2605"
1168
+ },
1169
+ n
1170
+ ))
1171
+ }
1172
+ ) });
698
1173
  }
699
- function LinearScale({ field, value, error, setValue }) {
1174
+ function LinearScale({ field, value, error, setValue, ids: providedIds }) {
1175
+ const ids = useFilloFieldIds(field.id, providedIds);
700
1176
  const scale = field;
701
1177
  const min = scale.min ?? 1;
702
1178
  const max = scale.max ?? 10;
703
1179
  const steps = Array.from({ length: max - min + 1 }, (_, i) => min + i);
704
- return /* @__PURE__ */ jsxs3(FieldShell, { field, error, children: [
705
- /* @__PURE__ */ jsx3("div", { className: "fillo-scale", role: "radiogroup", "aria-label": field.label, ...fieldAria(field, error), children: steps.map((n) => /* @__PURE__ */ jsx3(
706
- "button",
1180
+ const current = typeof value === "number" ? value : null;
1181
+ const tabbable = rovingIndex(steps, current);
1182
+ const hasLabels = Boolean(scale.minLabel || scale.maxLabel);
1183
+ const minLabelId = `${ids.inputId}-min`;
1184
+ const maxLabelId = `${ids.inputId}-max`;
1185
+ const describedBy = [
1186
+ scale.minLabel ? minLabelId : null,
1187
+ scale.maxLabel ? maxLabelId : null,
1188
+ field.description ? ids.descriptionId : null,
1189
+ error ? ids.errorId : null
1190
+ ].filter(Boolean).join(" ") || void 0;
1191
+ return /* @__PURE__ */ jsxs4(FieldShell, { field, error, ids, children: [
1192
+ /* @__PURE__ */ jsx4(
1193
+ "div",
707
1194
  {
708
- type: "button",
709
- className: `fillo-scale-step${value === n ? " fillo-scale-step--active" : ""}`,
710
- "aria-pressed": value === n,
711
- onClick: () => setValue(value === n ? null : n),
712
- children: n
713
- },
714
- n
715
- )) }),
716
- (scale.minLabel || scale.maxLabel) && /* @__PURE__ */ jsxs3("div", { className: "fillo-scale-labels", children: [
717
- /* @__PURE__ */ jsx3("span", { children: scale.minLabel }),
718
- /* @__PURE__ */ jsx3("span", { children: scale.maxLabel })
1195
+ className: "fillo-scale",
1196
+ role: "radiogroup",
1197
+ "aria-labelledby": ids.labelId,
1198
+ "aria-invalid": error ? true : void 0,
1199
+ "aria-required": field.required ? true : void 0,
1200
+ "aria-describedby": describedBy,
1201
+ onKeyDown: (e) => radiogroupKeyDown(e, steps, current, (n) => setValue(n)),
1202
+ children: steps.map((n, i) => /* @__PURE__ */ jsx4(
1203
+ "button",
1204
+ {
1205
+ type: "button",
1206
+ className: `fillo-scale-step${value === n ? " fillo-scale-step--active" : ""}`,
1207
+ role: "radio",
1208
+ "aria-label": String(n),
1209
+ "aria-checked": value === n,
1210
+ tabIndex: i === tabbable ? 0 : -1,
1211
+ onClick: () => setValue(value === n ? null : n),
1212
+ children: n
1213
+ },
1214
+ n
1215
+ ))
1216
+ }
1217
+ ),
1218
+ hasLabels && /* @__PURE__ */ jsxs4("div", { className: "fillo-scale-labels", children: [
1219
+ /* @__PURE__ */ jsx4("span", { id: minLabelId, children: scale.minLabel }),
1220
+ /* @__PURE__ */ jsx4("span", { id: maxLabelId, children: scale.maxLabel })
719
1221
  ] })
720
1222
  ] });
721
1223
  }
722
- function Ranking({ field, value, error, setValue }) {
1224
+ function Ranking({ field, value, error, setValue, ids: providedIds }) {
1225
+ const ids = useFilloFieldIds(field.id, providedIds);
723
1226
  const ranking = field;
724
1227
  const answered = Array.isArray(value) ? value : [];
725
1228
  const order = [
@@ -734,14 +1237,14 @@ function Ranking({ field, value, error, setValue }) {
734
1237
  [next[index], next[target]] = [next[target], next[index]];
735
1238
  setValue(next);
736
1239
  }
737
- return /* @__PURE__ */ jsx3(FieldShell, { field, error, children: /* @__PURE__ */ jsx3("ol", { className: "fillo-ranking", children: order.map((id, index) => {
1240
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4("ol", { className: "fillo-ranking", children: order.map((id, index) => {
738
1241
  const option = ranking.options.find((o) => o.id === id);
739
1242
  if (!option) return null;
740
- return /* @__PURE__ */ jsxs3("li", { className: "fillo-ranking-item", children: [
741
- /* @__PURE__ */ jsx3("span", { className: "fillo-ranking-index", children: index + 1 }),
742
- /* @__PURE__ */ jsx3("span", { className: "fillo-ranking-label", children: option.label }),
743
- /* @__PURE__ */ jsxs3("span", { className: "fillo-ranking-controls", children: [
744
- /* @__PURE__ */ jsx3(
1243
+ return /* @__PURE__ */ jsxs4("li", { className: "fillo-ranking-item", children: [
1244
+ /* @__PURE__ */ jsx4("span", { className: "fillo-ranking-index", children: index + 1 }),
1245
+ /* @__PURE__ */ jsx4("span", { className: "fillo-ranking-label", children: option.label }),
1246
+ /* @__PURE__ */ jsxs4("span", { className: "fillo-ranking-controls", children: [
1247
+ /* @__PURE__ */ jsx4(
745
1248
  "button",
746
1249
  {
747
1250
  type: "button",
@@ -752,7 +1255,7 @@ function Ranking({ field, value, error, setValue }) {
752
1255
  children: "\u2191"
753
1256
  }
754
1257
  ),
755
- /* @__PURE__ */ jsx3(
1258
+ /* @__PURE__ */ jsx4(
756
1259
  "button",
757
1260
  {
758
1261
  type: "button",
@@ -767,28 +1270,39 @@ function Ranking({ field, value, error, setValue }) {
767
1270
  ] }, id);
768
1271
  }) }) });
769
1272
  }
770
- function Matrix({ field, value, error, setValue }) {
1273
+ function Matrix({ field, value, error, setValue, ids: providedIds }) {
1274
+ const ids = useFilloFieldIds(field.id, providedIds);
771
1275
  const matrix = field;
772
1276
  const answers = value && typeof value === "object" && !Array.isArray(value) ? value : {};
773
- return /* @__PURE__ */ jsx3(FieldShell, { field, error, children: /* @__PURE__ */ jsx3("div", { className: "fillo-matrix-wrap", children: /* @__PURE__ */ jsxs3("table", { className: "fillo-matrix", children: [
774
- /* @__PURE__ */ jsx3("thead", { children: /* @__PURE__ */ jsxs3("tr", { children: [
775
- /* @__PURE__ */ jsx3("th", {}),
776
- matrix.columns.map((col) => /* @__PURE__ */ jsx3("th", { scope: "col", children: col.label }, col.id))
1277
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4("div", { className: "fillo-matrix-wrap", children: /* @__PURE__ */ jsxs4("table", { className: "fillo-matrix", children: [
1278
+ /* @__PURE__ */ jsx4("thead", { children: /* @__PURE__ */ jsxs4("tr", { children: [
1279
+ /* @__PURE__ */ jsx4("th", {}),
1280
+ matrix.columns.map((col) => /* @__PURE__ */ jsx4("th", { scope: "col", children: col.label }, col.id))
777
1281
  ] }) }),
778
- /* @__PURE__ */ jsx3("tbody", { children: matrix.rows.map((row) => /* @__PURE__ */ jsxs3("tr", { children: [
779
- /* @__PURE__ */ jsx3("th", { scope: "row", children: row.label }),
780
- matrix.columns.map((col) => /* @__PURE__ */ jsx3("td", { children: /* @__PURE__ */ jsx3(
781
- "input",
782
- {
783
- type: "radio",
784
- className: "fillo-option-input",
785
- name: `fillo-${field.id}-${row.id}`,
786
- "aria-label": `${row.label}: ${col.label}`,
787
- checked: answers[row.id] === col.id,
788
- onChange: () => setValue({ ...answers, [row.id]: col.id })
789
- }
790
- ) }, col.id))
791
- ] }, row.id)) })
1282
+ /* @__PURE__ */ jsx4("tbody", { children: matrix.rows.map((row) => {
1283
+ const rowLabelId = `${ids.inputId}-${row.id}`;
1284
+ const answered = answers[row.id] !== void 0;
1285
+ const rowInvalid = Boolean(error) && field.required && !answered;
1286
+ return (
1287
+ // Each row is its own radiogroup, labelled by the row question.
1288
+ /* @__PURE__ */ jsxs4("tr", { role: "radiogroup", "aria-labelledby": rowLabelId, children: [
1289
+ /* @__PURE__ */ jsx4("th", { scope: "row", id: rowLabelId, children: row.label }),
1290
+ matrix.columns.map((col) => /* @__PURE__ */ jsx4("td", { children: /* @__PURE__ */ jsx4(
1291
+ "input",
1292
+ {
1293
+ type: "radio",
1294
+ className: "fillo-option-input",
1295
+ name: `${ids.name}-${row.id}`,
1296
+ "aria-label": `${row.label}: ${col.label}`,
1297
+ "aria-required": field.required ? true : void 0,
1298
+ "aria-invalid": rowInvalid ? true : void 0,
1299
+ checked: answers[row.id] === col.id,
1300
+ onChange: () => setValue({ ...answers, [row.id]: col.id })
1301
+ }
1302
+ ) }, col.id))
1303
+ ] }, row.id)
1304
+ );
1305
+ }) })
792
1306
  ] }) }) });
793
1307
  }
794
1308
  function Hidden() {
@@ -798,7 +1312,7 @@ var DEFAULT_COMPONENTS = {
798
1312
  short_text: TextInput,
799
1313
  email: TextInput,
800
1314
  url: TextInput,
801
- phone: TextInput,
1315
+ phone: PhoneField,
802
1316
  number: TextInput,
803
1317
  date: TextInput,
804
1318
  long_text: LongText,
@@ -817,21 +1331,66 @@ var DEFAULT_COMPONENTS = {
817
1331
  function ContentRenderer({ block }) {
818
1332
  switch (block.kind) {
819
1333
  case "heading":
820
- return /* @__PURE__ */ jsx3("h3", { className: "fillo-heading", children: block.text });
1334
+ return /* @__PURE__ */ jsx4("h3", { className: "fillo-heading", children: block.text });
821
1335
  case "paragraph":
822
- return /* @__PURE__ */ jsx3("p", { className: "fillo-paragraph", children: block.text });
1336
+ return /* @__PURE__ */ jsx4("p", { className: "fillo-paragraph", children: block.text });
823
1337
  case "divider":
824
- return /* @__PURE__ */ jsx3("hr", { className: "fillo-divider" });
1338
+ return /* @__PURE__ */ jsx4("hr", { className: "fillo-divider" });
825
1339
  }
826
1340
  }
827
- function BlockRenderer({
828
- block,
829
- api,
830
- components,
831
- customComponents
832
- }) {
1341
+ function shouldAutoSubmit(field, value, api) {
1342
+ if (api.form.settings.submitMode !== "auto") return false;
1343
+ if (!api.isLastPage || api.status !== "idle" || api.uploading) return false;
1344
+ const hasDiscreteAnswer = (() => {
1345
+ switch (field.kind) {
1346
+ case "select":
1347
+ case "dropdown":
1348
+ return typeof value === "string" && field.options.some((option) => option.id === value);
1349
+ case "checkbox":
1350
+ return value === true;
1351
+ case "rating":
1352
+ case "linear_scale":
1353
+ return typeof value === "number";
1354
+ default:
1355
+ return false;
1356
+ }
1357
+ })();
1358
+ if (!hasDiscreteAnswer) return false;
1359
+ if (value === api.data[field.id]) return false;
1360
+ return validateResponse(api.form, { ...api.data, [field.id]: value }).ok;
1361
+ }
1362
+ function blockHasPiping(block) {
1363
+ const has = (s) => typeof s === "string" && s.includes("{{");
1364
+ switch (block.kind) {
1365
+ case "heading":
1366
+ case "paragraph":
1367
+ return has(block.text);
1368
+ case "divider":
1369
+ return false;
1370
+ default:
1371
+ return has(block.label) || has(block.description);
1372
+ }
1373
+ }
1374
+ function blockPropsEqual(prev, next) {
1375
+ if (prev.block !== next.block || prev.components !== next.components || prev.customComponents !== next.customComponents) {
1376
+ return false;
1377
+ }
1378
+ const id = next.block.id;
1379
+ if (prev.api.data[id] !== next.api.data[id]) return false;
1380
+ if (prev.api.errors[id] !== next.api.errors[id]) return false;
1381
+ const needsWiderState = next.block.kind === "custom" || blockHasPiping(prev.block) || blockHasPiping(next.block);
1382
+ if (needsWiderState && prev.api.data !== next.api.data) return false;
1383
+ if (next.api.form.settings.submitMode === "auto") {
1384
+ if (prev.api.data !== next.api.data || prev.api.status !== next.api.status || prev.api.uploading !== next.api.uploading || prev.api.isLastPage !== next.api.isLastPage) {
1385
+ return false;
1386
+ }
1387
+ }
1388
+ return true;
1389
+ }
1390
+ function BlockRendererInner({ block, api, components, customComponents }) {
833
1391
  block = pipeBlock(block, api.data, api.form);
834
- if (!isField(block)) return /* @__PURE__ */ jsx3(ContentRenderer, { block });
1392
+ const ids = useFilloFieldIds(block.id);
1393
+ if (!isField(block)) return /* @__PURE__ */ jsx4(ContentRenderer, { block });
835
1394
  let Component;
836
1395
  if (block.kind === "custom") {
837
1396
  Component = customComponents?.[block.component] ?? components?.custom;
@@ -847,17 +1406,22 @@ function BlockRenderer({
847
1406
  } else {
848
1407
  Component = components?.[block.kind] ?? DEFAULT_COMPONENTS[block.kind];
849
1408
  }
850
- return /* @__PURE__ */ jsx3(
1409
+ return /* @__PURE__ */ jsx4(
851
1410
  Component,
852
1411
  {
853
1412
  field: block,
854
1413
  value: api.data[block.id],
855
1414
  error: api.errors[block.id],
856
- setValue: (v) => api.setValue(block.id, v),
857
- api
1415
+ setValue: (v) => {
1416
+ api.setValue(block.id, v);
1417
+ if (shouldAutoSubmit(block, v, api)) void api.submit();
1418
+ },
1419
+ api,
1420
+ ids
858
1421
  }
859
1422
  );
860
1423
  }
1424
+ var BlockRenderer = memo(BlockRendererInner, blockPropsEqual);
861
1425
  function FormField({
862
1426
  id,
863
1427
  components,
@@ -866,7 +1430,7 @@ function FormField({
866
1430
  const api = useFillo();
867
1431
  const block = api.form.pages.flatMap((p) => p.blocks).find((b) => b.id === id);
868
1432
  if (!block) return null;
869
- return /* @__PURE__ */ jsx3(
1433
+ return /* @__PURE__ */ jsx4(
870
1434
  BlockRenderer,
871
1435
  {
872
1436
  block,
@@ -878,34 +1442,70 @@ function FormField({
878
1442
  }
879
1443
 
880
1444
  // src/FilloForm.tsx
881
- import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
882
- function themeStyle(theme) {
883
- if (!theme) return void 0;
1445
+ import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1446
+ function themeProps(theme) {
1447
+ const safeTheme = normalizeFormTheme(theme);
1448
+ if (!safeTheme) return {};
884
1449
  const style = {};
885
- if (theme.primary) style["--fillo-primary"] = theme.primary;
886
- if (theme.background) style["--fillo-bg"] = theme.background;
887
- if (theme.text) style["--fillo-text"] = theme.text;
888
- if (theme.radius) style["--fillo-radius"] = theme.radius;
889
- if (theme.fontFamily) style["--fillo-font"] = theme.fontFamily;
890
- return style;
1450
+ if (safeTheme.primary) style["--fillo-primary"] = safeTheme.primary;
1451
+ if (safeTheme.background) style["--fillo-bg"] = safeTheme.background;
1452
+ if (safeTheme.text) style["--fillo-text"] = safeTheme.text;
1453
+ if (safeTheme.radius) style["--fillo-radius"] = safeTheme.radius;
1454
+ if (safeTheme.fontFamily) style["--fillo-font"] = safeTheme.fontFamily;
1455
+ const hasColorTheme = Boolean(safeTheme.primary || safeTheme.background || safeTheme.text);
1456
+ return {
1457
+ ...Object.keys(style).length ? { style } : {},
1458
+ ...safeTheme.colorScheme ? { "data-fillo-color-scheme": safeTheme.colorScheme } : {},
1459
+ ...hasColorTheme ? { "data-fillo-has-color-theme": "" } : {}
1460
+ };
1461
+ }
1462
+ function safeHttpUrl(href) {
1463
+ try {
1464
+ if (!href) return null;
1465
+ const url = new URL(href);
1466
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
1467
+ return url.toString();
1468
+ } catch {
1469
+ return null;
1470
+ }
1471
+ }
1472
+ function badgeUrl(href) {
1473
+ const url = new URL(safeHttpUrl(href) ?? "https://fillo.so");
1474
+ url.searchParams.set("utm_source", "form_badge");
1475
+ return url.toString();
1476
+ }
1477
+ function focusFirstInvalid(formEl) {
1478
+ if (!formEl || typeof window === "undefined") return;
1479
+ const run = () => {
1480
+ const target = formEl.querySelector('[aria-invalid="true"]');
1481
+ target?.focus?.();
1482
+ };
1483
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
1484
+ else setTimeout(run, 0);
1485
+ }
1486
+ function isAutoSubmitBlock(block) {
1487
+ return isField2(block) && (block.kind === "select" || block.kind === "dropdown" || block.kind === "checkbox" || block.kind === "rating" || block.kind === "linear_scale");
1488
+ }
1489
+ function needsExplicitSubmit(blocks) {
1490
+ return blocks.some((block) => isField2(block) && block.kind !== "hidden" && !isAutoSubmitBlock(block));
891
1491
  }
892
1492
  function FilloForm(props) {
893
1493
  const { form, formId } = props;
894
- const client = useMemo(
1494
+ const client = useMemo4(
895
1495
  () => props.client ?? (formId ? createClient() : void 0),
896
1496
  [props.client, formId]
897
1497
  );
898
- const [fetched, setFetched] = useState5(null);
899
- const [fetchedTheme, setFetchedTheme] = useState5(null);
900
- const [loadError, setLoadError] = useState5(null);
901
- const [closed, setClosed] = useState5(false);
902
- const [fetchedBranding, setFetchedBranding] = useState5(null);
1498
+ const [fetched, setFetched] = useState6(null);
1499
+ const [fetchedTheme, setFetchedTheme] = useState6(null);
1500
+ const [loadError, setLoadError] = useState6(null);
1501
+ const [closed, setClosed] = useState6(false);
1502
+ const [fetchedBranding, setFetchedBranding] = useState6(null);
903
1503
  const codeForm = isCodeForm2(form) ? form : null;
904
1504
  const inlineSchema = codeForm ? codeForm.schema : form;
905
1505
  const onError = props.onError;
906
1506
  const syncedFormId = useCodeFormSync(form, client, onError);
907
1507
  const needsFetch = !inlineSchema && Boolean(client && formId);
908
- useEffect6(() => {
1508
+ useEffect7(() => {
909
1509
  if (!needsFetch || !client || !formId) return;
910
1510
  let cancelled = false;
911
1511
  setFetched(null);
@@ -926,35 +1526,42 @@ function FilloForm(props) {
926
1526
  cancelled = true;
927
1527
  };
928
1528
  }, [needsFetch, client, formId, onError]);
929
- const schema = inlineSchema ?? fetched;
1529
+ const rawSchema = inlineSchema ?? fetched;
1530
+ const normalized = useMemo4(() => rawSchema ? normalizeFormSchema(rawSchema) : null, [rawSchema]);
1531
+ const schema = normalized?.ok ? normalized.schema : null;
930
1532
  const theme = props.theme ?? codeForm?.theme ?? fetchedTheme ?? void 0;
1533
+ if (normalized && !normalized.ok) {
1534
+ const error = new FilloError2(`This form could not be rendered: ${normalized.error}`, 422);
1535
+ if (props.renderError) return /* @__PURE__ */ jsx5(Fragment2, { children: props.renderError(error) });
1536
+ return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--error ${props.className ?? ""}`, children: "This form could not be rendered." });
1537
+ }
931
1538
  if (loadError) {
932
1539
  if (props.renderError) {
933
- return /* @__PURE__ */ jsx4(Fragment2, { children: props.renderError(loadError) });
1540
+ return /* @__PURE__ */ jsx5(Fragment2, { children: props.renderError(loadError) });
934
1541
  }
935
1542
  const message = loadError.status === 404 ? "Form not found \u2014 check the form id and that it's published." : loadError.status === 0 ? "Couldn't reach the server \u2014 check your connection or CORS." : "This form could not be loaded.";
936
- return /* @__PURE__ */ jsx4("div", { className: `fillo-form fillo-form--error ${props.className ?? ""}`, children: message });
1543
+ return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--error ${props.className ?? ""}`, children: message });
937
1544
  }
938
1545
  if (closed) {
939
- return /* @__PURE__ */ jsx4("div", { className: `fillo-form fillo-form--closed ${props.className ?? ""}`, style: themeStyle(theme), children: /* @__PURE__ */ jsx4("p", { className: "fillo-closed", children: "This form is no longer accepting responses." }) });
1546
+ return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--closed ${props.className ?? ""}`, ...themeProps(theme), children: /* @__PURE__ */ jsx5("p", { className: "fillo-closed", children: "This form is no longer accepting responses." }) });
940
1547
  }
941
1548
  if (!schema) {
942
- return /* @__PURE__ */ jsxs4(
1549
+ return /* @__PURE__ */ jsxs5(
943
1550
  "div",
944
1551
  {
945
1552
  className: `fillo-form fillo-form--loading ${props.className ?? ""}`,
946
1553
  "aria-busy": "true",
947
- style: themeStyle(theme),
1554
+ ...themeProps(theme),
948
1555
  children: [
949
- /* @__PURE__ */ jsx4("div", { className: "fillo-skeleton" }),
950
- /* @__PURE__ */ jsx4("div", { className: "fillo-skeleton" }),
951
- /* @__PURE__ */ jsx4("div", { className: "fillo-skeleton fillo-skeleton--short" })
1556
+ /* @__PURE__ */ jsx5("div", { className: "fillo-skeleton" }),
1557
+ /* @__PURE__ */ jsx5("div", { className: "fillo-skeleton" }),
1558
+ /* @__PURE__ */ jsx5("div", { className: "fillo-skeleton fillo-skeleton--short" })
952
1559
  ]
953
1560
  }
954
1561
  );
955
1562
  }
956
- const poweredBy = props.skipValidation ? false : props.poweredBy ?? fetchedBranding?.poweredBy ?? true;
957
- return /* @__PURE__ */ jsx4(
1563
+ const poweredBy = fetchedBranding?.poweredBy ?? true;
1564
+ return /* @__PURE__ */ jsx5(
958
1565
  ResolvedForm,
959
1566
  {
960
1567
  ...props,
@@ -969,24 +1576,23 @@ function FilloForm(props) {
969
1576
  );
970
1577
  }
971
1578
  function PoweredBy({ label, href }) {
972
- const base = href ?? "https://fillo.so";
973
- const url = `${base}${base.includes("?") ? "&" : "?"}utm_source=form_badge`;
974
- return /* @__PURE__ */ jsx4("a", { className: "fillo-powered", href: url, target: "_blank", rel: "noopener", children: label ?? "Powered by Fillo" });
1579
+ return /* @__PURE__ */ jsx5("a", { className: "fillo-powered", href: badgeUrl(href), target: "_blank", rel: "noopener noreferrer", children: label ?? "Powered by Fillo" });
975
1580
  }
976
1581
  function isDevEnv() {
977
1582
  return typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
978
1583
  }
979
1584
  function DevNoClientNotice() {
980
- return /* @__PURE__ */ jsxs4("div", { className: "fillo-devwarning", role: "alert", children: [
981
- /* @__PURE__ */ jsx4("strong", { children: "No Fillo client connected." }),
1585
+ return /* @__PURE__ */ jsxs5("div", { className: "fillo-devwarning", role: "alert", children: [
1586
+ /* @__PURE__ */ jsx5("strong", { children: "No Fillo client connected." }),
982
1587
  " This form is render-only and won\u2019t save responses. Pass a ",
983
- /* @__PURE__ */ jsx4("code", { children: "client" }),
1588
+ /* @__PURE__ */ jsx5("code", { children: "client" }),
984
1589
  " to collect them in Fillo, or forward them with webhooks. (This notice only appears in development.)"
985
1590
  ] });
986
1591
  }
987
1592
  function ResolvedForm(props) {
988
1593
  const { schema, theme } = props;
989
- const hpRef = useRef4("");
1594
+ const instanceId = useId2().replace(/:/g, "");
1595
+ const hpRef = useRef5("");
990
1596
  const api = useFilloController({
991
1597
  form: schema,
992
1598
  formId: props.formId,
@@ -997,40 +1603,46 @@ function ResolvedForm(props) {
997
1603
  skipValidation: props.skipValidation,
998
1604
  getHoneypot: () => hpRef.current
999
1605
  });
1000
- const style = useMemo(() => themeStyle(theme), [theme]);
1606
+ const themed = useMemo4(() => themeProps(theme), [theme]);
1001
1607
  const settings = schema.settings;
1002
1608
  const submitted = api.status === "submitted";
1003
- useEffect6(() => {
1609
+ useEffect7(() => {
1004
1610
  if (!submitted || !settings.redirectUrl || typeof window === "undefined") return;
1005
- if (/^https?:\/\//i.test(settings.redirectUrl)) {
1006
- window.location.assign(settings.redirectUrl);
1007
- }
1611
+ const url = safeHttpUrl(settings.redirectUrl);
1612
+ if (url) window.location.assign(url);
1008
1613
  }, [submitted, settings.redirectUrl]);
1009
1614
  if (api.status === "submitted") {
1010
- return /* @__PURE__ */ jsxs4("div", { className: `fillo-form fillo-form--success ${props.className ?? ""}`, style, children: [
1011
- props.renderSuccess ? props.renderSuccess() : /* @__PURE__ */ jsxs4("div", { className: "fillo-success", children: [
1012
- /* @__PURE__ */ jsx4("div", { className: "fillo-success-mark", "aria-hidden": "true", children: "\u2713" }),
1013
- /* @__PURE__ */ jsx4("h2", { className: "fillo-success-title", children: settings.successTitle ?? "Thanks!" }),
1014
- /* @__PURE__ */ jsx4("p", { className: "fillo-success-message", children: settings.successMessage ?? "Your response has been recorded." })
1615
+ return /* @__PURE__ */ jsxs5("div", { className: `fillo-form fillo-form--success ${props.className ?? ""}`, ...themed, children: [
1616
+ props.renderSuccess ? props.renderSuccess() : /* @__PURE__ */ jsxs5("div", { className: "fillo-success", children: [
1617
+ /* @__PURE__ */ jsx5("div", { className: "fillo-success-mark", "aria-hidden": "true", children: "\u2713" }),
1618
+ /* @__PURE__ */ jsx5("h2", { className: "fillo-success-title", children: settings.successTitle ?? "Thanks!" }),
1619
+ /* @__PURE__ */ jsx5("p", { className: "fillo-success-message", children: settings.successMessage ?? "Your response has been recorded." })
1015
1620
  ] }),
1016
- props.poweredBy && /* @__PURE__ */ jsx4(PoweredBy, { label: props.brandLabel, href: props.brandHref })
1621
+ props.poweredBy && /* @__PURE__ */ jsx5(PoweredBy, { label: props.brandLabel, href: props.brandHref })
1017
1622
  ] });
1018
1623
  }
1019
1624
  const multiPage = api.pageCount > 1;
1020
- return /* @__PURE__ */ jsx4(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsxs4(
1625
+ const autoSubmit = settings.submitMode === "auto";
1626
+ const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(api.blocks);
1627
+ const showFooter = showPrimaryButton || multiPage && !api.isFirstPage;
1628
+ return /* @__PURE__ */ jsx5(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx5(FilloInstanceIdContext.Provider, { value: instanceId, children: /* @__PURE__ */ jsxs5(
1021
1629
  "form",
1022
1630
  {
1023
1631
  className: `fillo-form ${props.className ?? ""}`,
1024
- style,
1632
+ ...themed,
1025
1633
  noValidate: true,
1026
1634
  onSubmit: (e) => {
1027
1635
  e.preventDefault();
1028
- if (api.isLastPage) void api.submit();
1029
- else api.next();
1636
+ const formEl = e.currentTarget;
1637
+ if (api.isLastPage) void api.submit().then(() => focusFirstInvalid(formEl));
1638
+ else {
1639
+ api.next();
1640
+ focusFirstInvalid(formEl);
1641
+ }
1030
1642
  },
1031
1643
  children: [
1032
- !props.client && !props.skipValidation && isDevEnv() && /* @__PURE__ */ jsx4(DevNoClientNotice, {}),
1033
- multiPage && settings.showProgress !== false && /* @__PURE__ */ jsx4(
1644
+ !props.client && !props.skipValidation && isDevEnv() && /* @__PURE__ */ jsx5(DevNoClientNotice, {}),
1645
+ multiPage && settings.showProgress !== false && /* @__PURE__ */ jsx5(
1034
1646
  "div",
1035
1647
  {
1036
1648
  className: "fillo-progress-track",
@@ -1038,7 +1650,7 @@ function ResolvedForm(props) {
1038
1650
  "aria-valuemin": 0,
1039
1651
  "aria-valuemax": api.pageCount,
1040
1652
  "aria-valuenow": api.pageIndex + 1,
1041
- children: /* @__PURE__ */ jsx4(
1653
+ children: /* @__PURE__ */ jsx5(
1042
1654
  "div",
1043
1655
  {
1044
1656
  className: "fillo-progress-fill",
@@ -1047,12 +1659,12 @@ function ResolvedForm(props) {
1047
1659
  )
1048
1660
  }
1049
1661
  ),
1050
- api.pageIndex === 0 && props.showTitle !== false && /* @__PURE__ */ jsxs4("header", { className: "fillo-header", children: [
1051
- /* @__PURE__ */ jsx4("h1", { className: "fillo-title", children: schema.title }),
1052
- schema.description && /* @__PURE__ */ jsx4("p", { className: "fillo-form-description", children: schema.description })
1662
+ api.pageIndex === 0 && props.showTitle !== false && /* @__PURE__ */ jsxs5("header", { className: "fillo-header", children: [
1663
+ /* @__PURE__ */ jsx5("h1", { className: "fillo-title", children: schema.title }),
1664
+ schema.description && /* @__PURE__ */ jsx5("p", { className: "fillo-form-description", children: schema.description })
1053
1665
  ] }),
1054
- multiPage && api.page.title && api.pageIndex > 0 && /* @__PURE__ */ jsx4("h2", { className: "fillo-page-title", children: api.page.title }),
1055
- /* @__PURE__ */ jsx4("div", { className: "fillo-blocks", children: api.blocks.map((block) => /* @__PURE__ */ jsx4(
1666
+ multiPage && api.page.title && api.pageIndex > 0 && /* @__PURE__ */ jsx5("h2", { className: "fillo-page-title", children: api.page.title }),
1667
+ /* @__PURE__ */ jsx5("div", { className: "fillo-blocks", children: api.blocks.map((block) => /* @__PURE__ */ jsx5(
1056
1668
  BlockRenderer,
1057
1669
  {
1058
1670
  block,
@@ -1062,7 +1674,7 @@ function ResolvedForm(props) {
1062
1674
  },
1063
1675
  block.id
1064
1676
  )) }),
1065
- /* @__PURE__ */ jsx4(
1677
+ /* @__PURE__ */ jsx5(
1066
1678
  "input",
1067
1679
  {
1068
1680
  type: "text",
@@ -1077,9 +1689,10 @@ function ResolvedForm(props) {
1077
1689
  }
1078
1690
  }
1079
1691
  ),
1080
- /* @__PURE__ */ jsxs4("footer", { className: "fillo-footer", children: [
1081
- multiPage && !api.isFirstPage && /* @__PURE__ */ jsx4("button", { type: "button", className: "fillo-button fillo-button--ghost", onClick: api.back, children: "Back" }),
1082
- /* @__PURE__ */ jsx4(
1692
+ api.status === "error" && /* @__PURE__ */ jsx5("p", { className: "fillo-submit-error", role: "alert", children: "This form can't submit right now. Please try again in a moment." }),
1693
+ showFooter && /* @__PURE__ */ jsxs5("footer", { className: "fillo-footer", children: [
1694
+ multiPage && !api.isFirstPage && /* @__PURE__ */ jsx5("button", { type: "button", className: "fillo-button fillo-button--ghost", onClick: api.back, children: "Back" }),
1695
+ showPrimaryButton && /* @__PURE__ */ jsx5(
1083
1696
  "button",
1084
1697
  {
1085
1698
  type: "submit",
@@ -1089,17 +1702,26 @@ function ResolvedForm(props) {
1089
1702
  }
1090
1703
  )
1091
1704
  ] }),
1092
- props.poweredBy && /* @__PURE__ */ jsx4(PoweredBy, { label: props.brandLabel, href: props.brandHref })
1705
+ props.poweredBy && /* @__PURE__ */ jsx5(PoweredBy, { label: props.brandLabel, href: props.brandHref })
1093
1706
  ]
1094
1707
  }
1095
- ) });
1708
+ ) }) });
1096
1709
  }
1097
1710
 
1098
1711
  // src/provider.tsx
1099
- import { jsx as jsx5 } from "react/jsx-runtime";
1712
+ import { useId as useId3, useMemo as useMemo5 } from "react";
1713
+ import { FilloError as FilloError3, normalizeFormSchema as normalizeFormSchema2 } from "@usefillo/core";
1714
+ import { jsx as jsx6 } from "react/jsx-runtime";
1100
1715
  function FilloProvider({ children, form, formId, client, ...options }) {
1716
+ const instanceId = useId3().replace(/:/g, "");
1101
1717
  const codeForm = isCodeForm2(form) ? form : null;
1102
- const schema = codeForm ? codeForm.schema : form;
1718
+ const rawSchema = codeForm ? codeForm.schema : form;
1719
+ const schemaKey = useMemo5(() => JSON.stringify(rawSchema), [rawSchema]);
1720
+ const normalized = useMemo5(() => normalizeFormSchema2(rawSchema), [rawSchema, schemaKey]);
1721
+ if (!normalized.ok) {
1722
+ throw new FilloError3(`This form could not be rendered: ${normalized.error}`, 422);
1723
+ }
1724
+ const schema = normalized.schema;
1103
1725
  const syncedFormId = useCodeFormSync(form, client);
1104
1726
  const api = useFilloController({
1105
1727
  ...options,
@@ -1110,7 +1732,7 @@ function FilloProvider({ children, form, formId, client, ...options }) {
1110
1732
  // a paid capability the server enforces when these responses are submitted.
1111
1733
  surface: "headless"
1112
1734
  });
1113
- return /* @__PURE__ */ jsx5(FilloContext.Provider, { value: api, children });
1735
+ return /* @__PURE__ */ jsx6(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx6(FilloInstanceIdContext.Provider, { value: instanceId, children }) });
1114
1736
  }
1115
1737
 
1116
1738
  // src/index.ts
@@ -1118,12 +1740,12 @@ import {
1118
1740
  createClient as createClient2,
1119
1741
  provisionWorkspace,
1120
1742
  FilloClient,
1121
- FilloError as FilloError3
1743
+ FilloError as FilloError4
1122
1744
  } from "@usefillo/core";
1123
1745
  export {
1124
1746
  BlockRenderer,
1125
1747
  FilloClient,
1126
- FilloError3 as FilloError,
1748
+ FilloError4 as FilloError,
1127
1749
  FileUploadField as FilloFileUpload,
1128
1750
  FilloForm,
1129
1751
  FilloProvider,