@usefillo/react 0.3.1 → 0.4.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,18 +1,44 @@
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 useMemo2, 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 } 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
44
  const field = allFields(api.form).find((f) => f.id === fieldId);
@@ -96,17 +122,245 @@ function useCodeFormSync(form, client, onError) {
96
122
  }
97
123
 
98
124
  // src/fields.tsx
99
- import { isField, pipeBlock } from "@usefillo/core";
100
- import { useEffect as useEffect5, useState as useState4 } from "react";
125
+ import { isField, pipeBlock, validateResponse } from "@usefillo/core";
126
+ import { useEffect as useEffect6, useState as useState5 } from "react";
101
127
 
102
- // src/signature.tsx
103
- import { useEffect as useEffect3, useRef as useRef2, useState as useState2 } from "react";
128
+ // src/phone.tsx
129
+ import {
130
+ PHONE_COUNTRIES,
131
+ countryByIso,
132
+ digitsOnly,
133
+ flagEmoji,
134
+ formatNational,
135
+ parsePhone,
136
+ toE164
137
+ } from "@usefillo/core";
138
+ import { useEffect as useEffect3, useLayoutEffect, useMemo, useRef as useRef2, useState as useState2 } from "react";
104
139
  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));
140
+ function localeCountry() {
141
+ if (typeof navigator === "undefined") return void 0;
142
+ const tag = navigator.languages?.[0] || navigator.language || "";
143
+ const region = tag.split("-")[1];
144
+ return region && /^[A-Za-z]{2}$/.test(region) ? region.toUpperCase() : void 0;
145
+ }
146
+ function nationalFor(stored, country) {
147
+ const digits = digitsOnly(stored);
148
+ return digits.startsWith(country.dialCode) ? digits.slice(country.dialCode.length) : digits;
149
+ }
150
+ function PhoneField({ field, value, error, setValue, ids: providedIds }) {
151
+ const ids = useFilloFieldIds(field.id, providedIds);
152
+ const schema = field;
153
+ const stored = typeof value === "string" ? value : "";
154
+ const [country, setCountry] = useState2(
155
+ () => parsePhone(stored).country ?? countryByIso(schema.defaultCountry) ?? countryByIso(localeCountry()) ?? PHONE_COUNTRIES[0]
156
+ );
157
+ const national = nationalFor(stored, country);
158
+ const display = formatNational(country, national);
159
+ const inputRef = useRef2(null);
160
+ const caretDigits = useRef2(null);
161
+ useLayoutEffect(() => {
162
+ const el = inputRef.current;
163
+ if (!el || caretDigits.current == null) return;
164
+ const target = caretDigits.current;
165
+ caretDigits.current = null;
166
+ if (target === 0) return el.setSelectionRange(0, 0);
167
+ let seen = 0;
168
+ let pos = display.length;
169
+ for (let i = 0; i < display.length; i++) {
170
+ if (/\d/.test(display[i]) && ++seen === target) {
171
+ pos = i + 1;
172
+ break;
173
+ }
174
+ }
175
+ el.setSelectionRange(pos, pos);
176
+ });
177
+ const commit = (c, nat) => setValue(nat ? toE164(c, nat) : "");
178
+ function onInput(e) {
179
+ const el = e.target;
180
+ const raw = el.value;
181
+ const caret = el.selectionStart ?? raw.length;
182
+ caretDigits.current = digitsOnly(raw.slice(0, caret)).length;
183
+ if (raw.trimStart().startsWith("+")) {
184
+ const p = parsePhone(raw);
185
+ if (p.country) setCountry(p.country);
186
+ commit(p.country ?? country, p.country ? p.national : digitsOnly(raw));
187
+ return;
188
+ }
189
+ commit(country, digitsOnly(raw));
190
+ }
191
+ function pickCountry(c) {
192
+ setCountry(c);
193
+ commit(c, national);
194
+ inputRef.current?.focus();
195
+ }
196
+ const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ") || void 0;
197
+ return /* @__PURE__ */ jsxs("div", { className: `fillo-field fillo-field--phone${error ? " fillo-field--error" : ""}`, "data-field": field.id, children: [
198
+ /* @__PURE__ */ jsxs("label", { className: "fillo-label", id: ids.labelId, htmlFor: ids.inputId, children: [
199
+ field.label,
200
+ !field.required && /* @__PURE__ */ jsx("span", { className: "fillo-optional", children: " (optional)" })
201
+ ] }),
202
+ field.description && /* @__PURE__ */ jsx("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
203
+ /* @__PURE__ */ jsxs("div", { className: "fillo-phone", children: [
204
+ /* @__PURE__ */ jsx(CountrySelect, { country, onPick: pickCountry, labelledBy: ids.labelId }),
205
+ /* @__PURE__ */ jsx(
206
+ "input",
207
+ {
208
+ ref: inputRef,
209
+ id: ids.inputId,
210
+ className: "fillo-input fillo-phone-input",
211
+ type: "tel",
212
+ inputMode: "tel",
213
+ autoComplete: "tel-national",
214
+ value: display,
215
+ placeholder: formatNational(country, country.example),
216
+ onChange: onInput,
217
+ "aria-invalid": error ? true : void 0,
218
+ "aria-required": field.required ? true : void 0,
219
+ "aria-describedby": describedBy
220
+ }
221
+ )
222
+ ] }),
223
+ error && /* @__PURE__ */ jsx("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
224
+ ] });
225
+ }
226
+ function CountrySelect({
227
+ country,
228
+ onPick,
229
+ labelledBy
230
+ }) {
231
+ const [open, setOpen] = useState2(false);
232
+ const [query, setQuery] = useState2("");
233
+ const [active, setActive] = useState2(0);
234
+ const rootRef = useRef2(null);
235
+ const searchRef = useRef2(null);
236
+ const listId = `${labelledBy}-countries`;
237
+ const matches = useMemo(() => {
238
+ const q = query.trim().toLowerCase();
239
+ if (!q) return PHONE_COUNTRIES;
240
+ const qDigits = q.replace(/[^\d]/g, "");
241
+ return PHONE_COUNTRIES.filter(
242
+ (c) => c.name.toLowerCase().includes(q) || c.iso2.toLowerCase() === q || qDigits.length > 0 && c.dialCode.startsWith(qDigits)
243
+ );
244
+ }, [query]);
245
+ useEffect3(() => {
246
+ if (!open) return;
247
+ setQuery("");
248
+ setActive(Math.max(0, PHONE_COUNTRIES.indexOf(country)));
249
+ const id = requestAnimationFrame(() => searchRef.current?.focus());
250
+ return () => cancelAnimationFrame(id);
251
+ }, [open, country]);
109
252
  useEffect3(() => {
253
+ if (!open) return;
254
+ const onDown = (e) => {
255
+ if (!rootRef.current?.contains(e.target)) setOpen(false);
256
+ };
257
+ document.addEventListener("mousedown", onDown);
258
+ return () => document.removeEventListener("mousedown", onDown);
259
+ }, [open]);
260
+ useEffect3(() => {
261
+ if (!open) return;
262
+ document.getElementById(`${listId}-opt-${active}`)?.scrollIntoView({ block: "nearest" });
263
+ }, [active, open, listId]);
264
+ function choose(c) {
265
+ if (!c) return;
266
+ onPick(c);
267
+ setOpen(false);
268
+ }
269
+ function onSearchKey(e) {
270
+ if (e.key === "ArrowDown") {
271
+ e.preventDefault();
272
+ setActive((i) => Math.min(i + 1, matches.length - 1));
273
+ } else if (e.key === "ArrowUp") {
274
+ e.preventDefault();
275
+ setActive((i) => Math.max(i - 1, 0));
276
+ } else if (e.key === "Enter") {
277
+ e.preventDefault();
278
+ choose(matches[active]);
279
+ } else if (e.key === "Escape") {
280
+ e.preventDefault();
281
+ setOpen(false);
282
+ }
283
+ }
284
+ return /* @__PURE__ */ jsxs("div", { className: "fillo-phone-country", ref: rootRef, children: [
285
+ /* @__PURE__ */ jsxs(
286
+ "button",
287
+ {
288
+ type: "button",
289
+ className: "fillo-phone-flag",
290
+ "aria-haspopup": "listbox",
291
+ "aria-expanded": open,
292
+ "aria-label": `Country: ${country.name} (+${country.dialCode})`,
293
+ onClick: () => setOpen((o) => !o),
294
+ children: [
295
+ /* @__PURE__ */ jsx("span", { className: "fillo-phone-flag-emoji", "aria-hidden": "true", children: flagEmoji(country.iso2) }),
296
+ /* @__PURE__ */ jsxs("span", { className: "fillo-phone-dial", children: [
297
+ "+",
298
+ country.dialCode
299
+ ] }),
300
+ /* @__PURE__ */ jsx("span", { className: "fillo-phone-caret", "aria-hidden": "true", children: "\u25BE" })
301
+ ]
302
+ }
303
+ ),
304
+ open && /* @__PURE__ */ jsxs("div", { className: "fillo-phone-popover", children: [
305
+ /* @__PURE__ */ jsx(
306
+ "input",
307
+ {
308
+ ref: searchRef,
309
+ className: "fillo-phone-search",
310
+ type: "text",
311
+ role: "combobox",
312
+ "aria-expanded": "true",
313
+ "aria-controls": listId,
314
+ "aria-autocomplete": "list",
315
+ "aria-activedescendant": matches[active] ? `${listId}-opt-${active}` : void 0,
316
+ placeholder: "Search country or code",
317
+ value: query,
318
+ onChange: (e) => {
319
+ setQuery(e.target.value);
320
+ setActive(0);
321
+ },
322
+ onKeyDown: onSearchKey
323
+ }
324
+ ),
325
+ /* @__PURE__ */ jsxs("ul", { className: "fillo-phone-list", role: "listbox", id: listId, "aria-label": "Country", children: [
326
+ matches.map((c, i) => /* @__PURE__ */ jsxs(
327
+ "li",
328
+ {
329
+ id: `${listId}-opt-${i}`,
330
+ role: "option",
331
+ "aria-selected": c.iso2 === country.iso2,
332
+ className: `fillo-phone-option${i === active ? " fillo-phone-option--active" : ""}`,
333
+ onMouseEnter: () => setActive(i),
334
+ onMouseDown: (e) => {
335
+ e.preventDefault();
336
+ choose(c);
337
+ },
338
+ children: [
339
+ /* @__PURE__ */ jsx("span", { className: "fillo-phone-flag-emoji", "aria-hidden": "true", children: flagEmoji(c.iso2) }),
340
+ /* @__PURE__ */ jsx("span", { className: "fillo-phone-option-name", children: c.name }),
341
+ /* @__PURE__ */ jsxs("span", { className: "fillo-phone-option-dial", children: [
342
+ "+",
343
+ c.dialCode
344
+ ] })
345
+ ]
346
+ },
347
+ c.iso2
348
+ )),
349
+ matches.length === 0 && /* @__PURE__ */ jsx("li", { className: "fillo-phone-empty", children: "No matches" })
350
+ ] })
351
+ ] })
352
+ ] });
353
+ }
354
+
355
+ // src/signature.tsx
356
+ import { useEffect as useEffect4, useRef as useRef3, useState as useState3 } from "react";
357
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
358
+ function SignatureField({ field, value, error, setValue, ids: providedIds }) {
359
+ const ids = useFilloFieldIds(field.id, providedIds);
360
+ const canvasRef = useRef3(null);
361
+ const drawing = useRef3(false);
362
+ const [hasInk, setHasInk] = useState3(Boolean(value));
363
+ useEffect4(() => {
110
364
  const canvas = canvasRef.current;
111
365
  if (!canvas) return;
112
366
  const scale = window.devicePixelRatio || 1;
@@ -161,19 +415,19 @@ function SignatureField({ field, value, error, setValue }) {
161
415
  setHasInk(false);
162
416
  setValue(null);
163
417
  }
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: [
418
+ const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ") || void 0;
419
+ return /* @__PURE__ */ jsxs2("div", { className: `fillo-field fillo-field--signature${error ? " fillo-field--error" : ""}`, children: [
420
+ /* @__PURE__ */ jsxs2("label", { className: "fillo-label", htmlFor: ids.inputId, children: [
167
421
  field.label,
168
- !field.required && /* @__PURE__ */ jsx("span", { className: "fillo-optional", children: " (optional)" })
422
+ !field.required && /* @__PURE__ */ jsx2("span", { className: "fillo-optional", children: " (optional)" })
169
423
  ] }),
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(
424
+ field.description && /* @__PURE__ */ jsx2("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
425
+ /* @__PURE__ */ jsxs2("div", { className: "fillo-signature", children: [
426
+ /* @__PURE__ */ jsx2(
173
427
  "canvas",
174
428
  {
175
429
  ref: canvasRef,
176
- id: `fillo-${field.id}`,
430
+ id: ids.inputId,
177
431
  className: "fillo-signature-canvas",
178
432
  "aria-invalid": error ? true : void 0,
179
433
  "aria-describedby": describedBy,
@@ -183,31 +437,32 @@ function SignatureField({ field, value, error, setValue }) {
183
437
  onPointerLeave: end
184
438
  }
185
439
  ),
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" })
440
+ hasInk && /* @__PURE__ */ jsx2("button", { type: "button", className: "fillo-signature-clear", onClick: clear, children: "Clear" }),
441
+ !hasInk && /* @__PURE__ */ jsx2("span", { className: "fillo-signature-hint", children: "Sign here" })
188
442
  ] }),
189
- error && /* @__PURE__ */ jsx("p", { className: "fillo-error", id: `fillo-${field.id}-error`, role: "alert", children: error })
443
+ error && /* @__PURE__ */ jsx2("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
190
444
  ] });
191
445
  }
192
446
 
193
447
  // 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";
448
+ import { useEffect as useEffect5, useRef as useRef4, useState as useState4 } from "react";
449
+ import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
196
450
  function formatBytes(bytes) {
197
451
  if (bytes < 1024) return `${bytes} B`;
198
452
  if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(0)} KB`;
199
453
  if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
200
454
  return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
201
455
  }
202
- function FileUploadField({ field, value, error, setValue, api }) {
456
+ function FileUploadField({ field, value, error, setValue, api, ids: providedIds }) {
457
+ const ids = useFilloFieldIds(field.id, providedIds);
203
458
  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(
459
+ const inputRef = useRef4(null);
460
+ const [inFlight, setInFlight] = useState4([]);
461
+ const [dragOver, setDragOver] = useState4(false);
462
+ const mountedRef = useRef4(true);
463
+ const controllers = useRef4(/* @__PURE__ */ new Map());
464
+ const inFlightCount = useRef4(0);
465
+ useEffect5(
211
466
  () => () => {
212
467
  mountedRef.current = false;
213
468
  controllers.current.forEach((c) => c.abort());
@@ -271,14 +526,14 @@ function FileUploadField({ field, value, error, setValue, api }) {
271
526
  function removeFile(fileId) {
272
527
  setValue(files.filter((f) => f.fileId !== fileId));
273
528
  }
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: [
529
+ const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ") || void 0;
530
+ return /* @__PURE__ */ jsxs3("div", { className: `fillo-field fillo-field--file_upload${error ? " fillo-field--error" : ""}`, children: [
531
+ /* @__PURE__ */ jsxs3("label", { className: "fillo-label", htmlFor: ids.inputId, children: [
277
532
  field.label,
278
- !field.required && /* @__PURE__ */ jsx2("span", { className: "fillo-optional", children: " (optional)" })
533
+ !field.required && /* @__PURE__ */ jsx3("span", { className: "fillo-optional", children: " (optional)" })
279
534
  ] }),
280
- field.description && /* @__PURE__ */ jsx2("p", { className: "fillo-description", id: `fillo-${field.id}-desc`, children: field.description }),
281
- remaining > 0 && /* @__PURE__ */ jsxs2(
535
+ field.description && /* @__PURE__ */ jsx3("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
536
+ remaining > 0 && /* @__PURE__ */ jsxs3(
282
537
  "div",
283
538
  {
284
539
  className: `fillo-dropzone${dragOver ? " fillo-dropzone--over" : ""}${canUpload ? "" : " fillo-dropzone--disabled"}`,
@@ -299,11 +554,11 @@ function FileUploadField({ field, value, error, setValue, api }) {
299
554
  if ((e.key === "Enter" || e.key === " ") && canUpload) inputRef.current?.click();
300
555
  },
301
556
  children: [
302
- /* @__PURE__ */ jsx2(
557
+ /* @__PURE__ */ jsx3(
303
558
  "input",
304
559
  {
305
560
  ref: inputRef,
306
- id: `fillo-${field.id}`,
561
+ id: ids.inputId,
307
562
  type: "file",
308
563
  hidden: true,
309
564
  "aria-invalid": error ? true : void 0,
@@ -314,26 +569,26 @@ function FileUploadField({ field, value, error, setValue, api }) {
314
569
  onChange: (e) => handleFiles(e.target.files)
315
570
  }
316
571
  ),
317
- canUpload ? /* @__PURE__ */ jsxs2(Fragment, { children: [
318
- /* @__PURE__ */ jsxs2("span", { className: "fillo-dropzone-title", children: [
572
+ canUpload ? /* @__PURE__ */ jsxs3(Fragment, { children: [
573
+ /* @__PURE__ */ jsxs3("span", { className: "fillo-dropzone-title", children: [
319
574
  "Drop ",
320
575
  maxFiles > 1 ? "files" : "a file",
321
576
  " here or click to browse"
322
577
  ] }),
323
- /* @__PURE__ */ jsxs2("span", { className: "fillo-dropzone-hint", children: [
578
+ /* @__PURE__ */ jsxs3("span", { className: "fillo-dropzone-hint", children: [
324
579
  "Up to ",
325
580
  schema.maxFileSizeMb ?? 500,
326
581
  " MB per file \xB7 resumable"
327
582
  ] })
328
- ] }) : /* @__PURE__ */ jsx2("span", { className: "fillo-dropzone-hint", children: "Uploads are disabled in preview" })
583
+ ] }) : /* @__PURE__ */ jsx3("span", { className: "fillo-dropzone-hint", children: "Uploads are disabled in preview" })
329
584
  ]
330
585
  }
331
586
  ),
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(
587
+ (inFlight.length > 0 || files.length > 0) && /* @__PURE__ */ jsxs3("ul", { className: "fillo-files", children: [
588
+ files.map((f) => /* @__PURE__ */ jsxs3("li", { className: "fillo-file fillo-file--done", children: [
589
+ /* @__PURE__ */ jsx3("span", { className: "fillo-file-name", children: f.name }),
590
+ /* @__PURE__ */ jsx3("span", { className: "fillo-file-meta", children: formatBytes(f.size) }),
591
+ /* @__PURE__ */ jsx3(
337
592
  "button",
338
593
  {
339
594
  type: "button",
@@ -344,11 +599,11 @@ function FileUploadField({ field, value, error, setValue, api }) {
344
599
  }
345
600
  )
346
601
  ] }, 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(
602
+ inFlight.map((f) => /* @__PURE__ */ jsxs3("li", { className: `fillo-file${f.error ? " fillo-file--failed" : ""}`, children: [
603
+ /* @__PURE__ */ jsx3("span", { className: "fillo-file-name", children: f.name }),
604
+ f.error ? /* @__PURE__ */ jsxs3(Fragment, { children: [
605
+ /* @__PURE__ */ jsx3("span", { className: "fillo-file-meta", children: f.error }),
606
+ /* @__PURE__ */ jsx3(
352
607
  "button",
353
608
  {
354
609
  type: "button",
@@ -358,14 +613,14 @@ function FileUploadField({ field, value, error, setValue, api }) {
358
613
  children: "\xD7"
359
614
  }
360
615
  )
361
- ] }) : /* @__PURE__ */ jsxs2(Fragment, { children: [
362
- /* @__PURE__ */ jsxs2("span", { className: "fillo-file-meta", children: [
616
+ ] }) : /* @__PURE__ */ jsxs3(Fragment, { children: [
617
+ /* @__PURE__ */ jsxs3("span", { className: "fillo-file-meta", children: [
363
618
  Math.round(f.fraction * 100),
364
619
  "% of ",
365
620
  formatBytes(f.size)
366
621
  ] }),
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(
622
+ /* @__PURE__ */ jsx3("span", { className: "fillo-progress", "aria-hidden": "true", children: /* @__PURE__ */ jsx3("span", { className: "fillo-progress-bar", style: { width: `${f.fraction * 100}%` } }) }),
623
+ /* @__PURE__ */ jsx3(
369
624
  "button",
370
625
  {
371
626
  type: "button",
@@ -378,16 +633,16 @@ function FileUploadField({ field, value, error, setValue, api }) {
378
633
  ] })
379
634
  ] }, f.key))
380
635
  ] }),
381
- error && /* @__PURE__ */ jsx2("p", { className: "fillo-error", id: `fillo-${field.id}-error`, role: "alert", children: error })
636
+ error && /* @__PURE__ */ jsx3("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
382
637
  ] });
383
638
  }
384
639
 
385
640
  // src/fields.tsx
386
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
387
- function fieldAria(field, error) {
641
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
642
+ function fieldAria(field, error, ids) {
388
643
  const describedBy = [
389
- field.description ? `fillo-${field.id}-desc` : null,
390
- error ? `fillo-${field.id}-error` : null
644
+ field.description ? ids.descriptionId : null,
645
+ error ? ids.errorId : null
391
646
  ].filter(Boolean);
392
647
  return {
393
648
  "aria-invalid": error ? true : void 0,
@@ -398,58 +653,61 @@ function fieldAria(field, error) {
398
653
  function FieldShell({
399
654
  field,
400
655
  error,
656
+ ids,
401
657
  children
402
658
  }) {
403
- return /* @__PURE__ */ jsxs3(
659
+ return /* @__PURE__ */ jsxs4(
404
660
  "div",
405
661
  {
406
662
  className: `fillo-field fillo-field--${field.kind}${error ? " fillo-field--error" : ""}`,
407
663
  "data-field": field.id,
408
664
  children: [
409
- /* @__PURE__ */ jsxs3("label", { className: "fillo-label", id: `fillo-${field.id}-label`, htmlFor: `fillo-${field.id}`, children: [
665
+ /* @__PURE__ */ jsxs4("label", { className: "fillo-label", id: ids.labelId, htmlFor: ids.inputId, children: [
410
666
  field.label,
411
- !field.required && /* @__PURE__ */ jsx3("span", { className: "fillo-optional", children: " (optional)" })
667
+ !field.required && /* @__PURE__ */ jsx4("span", { className: "fillo-optional", children: " (optional)" })
412
668
  ] }),
413
- field.description && /* @__PURE__ */ jsx3("p", { className: "fillo-description", id: `fillo-${field.id}-desc`, children: field.description }),
669
+ field.description && /* @__PURE__ */ jsx4("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
414
670
  children,
415
- error && /* @__PURE__ */ jsx3("p", { className: "fillo-error", id: `fillo-${field.id}-error`, role: "alert", children: error })
671
+ error && /* @__PURE__ */ jsx4("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
416
672
  ]
417
673
  }
418
674
  );
419
675
  }
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(
676
+ function TextInput({ field, value, error, setValue, ids: providedIds }) {
677
+ const ids = useFilloFieldIds(field.id, providedIds);
678
+ const type = field.kind === "email" ? "email" : field.kind === "url" ? "url" : field.kind === "number" ? "number" : field.kind === "date" ? "date" : "text";
679
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4(
423
680
  "input",
424
681
  {
425
- id: `fillo-${field.id}`,
682
+ id: ids.inputId,
426
683
  className: "fillo-input",
427
684
  type,
428
685
  value: typeof value === "string" || typeof value === "number" ? String(value) : "",
429
686
  placeholder: field.placeholder,
430
687
  onChange: (e) => setValue(e.target.value),
431
- ...fieldAria(field, error)
688
+ ...fieldAria(field, error, ids)
432
689
  }
433
690
  ) });
434
691
  }
435
- function LongText({ field, value, error, setValue }) {
436
- return /* @__PURE__ */ jsx3(FieldShell, { field, error, children: /* @__PURE__ */ jsx3(
692
+ function LongText({ field, value, error, setValue, ids: providedIds }) {
693
+ const ids = useFilloFieldIds(field.id, providedIds);
694
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4(
437
695
  "textarea",
438
696
  {
439
- id: `fillo-${field.id}`,
697
+ id: ids.inputId,
440
698
  className: "fillo-input fillo-textarea",
441
699
  rows: 4,
442
700
  value: typeof value === "string" ? value : "",
443
701
  placeholder: field.placeholder,
444
702
  onChange: (e) => setValue(e.target.value),
445
- ...fieldAria(field, error)
703
+ ...fieldAria(field, error, ids)
446
704
  }
447
705
  ) });
448
706
  }
449
707
  function useDisplayOptions(choice) {
450
708
  const idsKey = choice.options.map((o) => o.id).join("\0");
451
- const [order, setOrder] = useState4(null);
452
- useEffect5(() => {
709
+ const [order, setOrder] = useState5(null);
710
+ useEffect6(() => {
453
711
  if (!choice.shuffleOptions) return;
454
712
  const ids = idsKey === "" ? [] : idsKey.split("\0");
455
713
  for (let i = ids.length - 1; i > 0; i--) {
@@ -463,32 +721,54 @@ function useDisplayOptions(choice) {
463
721
  const shuffled = order.flatMap((id) => byId.get(id) ?? []);
464
722
  return shuffled.length === choice.options.length ? shuffled : choice.options;
465
723
  }
466
- function SingleChoice({ field, value, error, setValue }) {
724
+ var OPTION_ICON_PATHS = {
725
+ 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",
726
+ 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"
727
+ };
728
+ function ChoiceIcon({ icon }) {
729
+ if (!icon) return null;
730
+ return /* @__PURE__ */ jsx4(
731
+ "svg",
732
+ {
733
+ className: "fillo-option-icon",
734
+ viewBox: "0 0 24 24",
735
+ fill: "none",
736
+ stroke: "currentColor",
737
+ strokeWidth: "1.8",
738
+ strokeLinecap: "round",
739
+ strokeLinejoin: "round",
740
+ "aria-hidden": "true",
741
+ children: /* @__PURE__ */ jsx4("path", { d: OPTION_ICON_PATHS[icon] })
742
+ }
743
+ );
744
+ }
745
+ function SingleChoice({ field, value, error, setValue, ids: providedIds }) {
746
+ const ids = useFilloFieldIds(field.id, providedIds);
467
747
  const choice = field;
468
748
  const options = useDisplayOptions(choice);
469
749
  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(
750
+ const [otherOn, setOtherOn] = useState5(isOtherValue);
751
+ const otherActive = isOtherValue || value === "" && otherOn;
752
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsxs4(
473
753
  "div",
474
754
  {
475
755
  className: "fillo-options",
476
756
  role: "radiogroup",
477
- "aria-labelledby": `fillo-${field.id}-label`,
478
- ...fieldAria(field, error),
757
+ "aria-labelledby": ids.labelId,
758
+ ...fieldAria(field, error, ids),
479
759
  children: [
480
- options.map((opt) => /* @__PURE__ */ jsxs3(
760
+ options.map((opt) => /* @__PURE__ */ jsxs4(
481
761
  "label",
482
762
  {
483
763
  "data-option": opt.id,
484
- className: `fillo-option${value === opt.id ? " fillo-option--selected" : ""}`,
764
+ className: `fillo-option${opt.icon ? " fillo-option--has-icon" : ""}${value === opt.id ? " fillo-option--selected" : ""}`,
485
765
  children: [
486
- /* @__PURE__ */ jsx3(
766
+ /* @__PURE__ */ jsx4(
487
767
  "input",
488
768
  {
489
769
  type: "radio",
490
770
  className: "fillo-option-input",
491
- name: `fillo-${field.id}`,
771
+ name: ids.name,
492
772
  checked: value === opt.id,
493
773
  onChange: () => {
494
774
  setOtherOn(false);
@@ -496,66 +776,76 @@ function SingleChoice({ field, value, error, setValue }) {
496
776
  }
497
777
  }
498
778
  ),
499
- /* @__PURE__ */ jsx3("span", { className: "fillo-option-label", children: opt.label })
779
+ /* @__PURE__ */ jsx4(ChoiceIcon, { icon: opt.icon }),
780
+ /* @__PURE__ */ jsx4("span", { className: "fillo-option-label", children: opt.label })
500
781
  ]
501
782
  },
502
783
  opt.id
503
784
  )),
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
- ] })
785
+ choice.allowOther && /* @__PURE__ */ jsxs4(
786
+ "label",
787
+ {
788
+ className: `fillo-option fillo-option--other${otherActive ? " fillo-option--selected fillo-option--with-other" : ""}`,
789
+ children: [
790
+ /* @__PURE__ */ jsxs4("span", { className: "fillo-option-main", children: [
791
+ /* @__PURE__ */ jsx4(
792
+ "input",
793
+ {
794
+ type: "radio",
795
+ className: "fillo-option-input",
796
+ name: ids.name,
797
+ checked: otherActive,
798
+ onChange: () => {
799
+ setOtherOn(true);
800
+ setValue("");
801
+ }
802
+ }
803
+ ),
804
+ /* @__PURE__ */ jsx4("span", { className: "fillo-option-label", children: "Other" })
805
+ ] }),
806
+ otherActive && /* @__PURE__ */ jsx4(
807
+ "input",
808
+ {
809
+ type: "text",
810
+ className: "fillo-input fillo-other-input",
811
+ placeholder: "Your answer",
812
+ autoFocus: !isOtherValue,
813
+ value: isOtherValue ? String(value) : "",
814
+ onChange: (e) => setValue(e.target.value)
815
+ }
816
+ )
817
+ ]
818
+ }
819
+ )
531
820
  ]
532
821
  }
533
822
  ) });
534
823
  }
535
- function MultiChoice({ field, value, error, setValue }) {
824
+ function MultiChoice({ field, value, error, setValue, ids: providedIds }) {
825
+ const ids = useFilloFieldIds(field.id, providedIds);
536
826
  const choice = field;
537
827
  const options = useDisplayOptions(choice);
538
- const ids = new Set(choice.options.map((o) => o.id));
828
+ const optionIds = new Set(choice.options.map((o) => o.id));
539
829
  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;
830
+ const otherText = selected.find((v) => !optionIds.has(v));
831
+ const [otherOn, setOtherOn] = useState5(otherText !== void 0);
832
+ const otherActive = otherText !== void 0 || otherOn;
543
833
  const toggle = (id) => setValue(selected.includes(id) ? selected.filter((v) => v !== id) : [...selected, id]);
544
- return /* @__PURE__ */ jsx3(FieldShell, { field, error, children: /* @__PURE__ */ jsxs3(
834
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsxs4(
545
835
  "div",
546
836
  {
547
837
  className: "fillo-options",
548
838
  role: "group",
549
- "aria-labelledby": `fillo-${field.id}-label`,
550
- ...fieldAria(field, error),
839
+ "aria-labelledby": ids.labelId,
840
+ ...fieldAria(field, error, ids),
551
841
  children: [
552
- options.map((opt) => /* @__PURE__ */ jsxs3(
842
+ options.map((opt) => /* @__PURE__ */ jsxs4(
553
843
  "label",
554
844
  {
555
845
  "data-option": opt.id,
556
- className: `fillo-option${selected.includes(opt.id) ? " fillo-option--selected" : ""}`,
846
+ className: `fillo-option${opt.icon ? " fillo-option--has-icon" : ""}${selected.includes(opt.id) ? " fillo-option--selected" : ""}`,
557
847
  children: [
558
- /* @__PURE__ */ jsx3(
848
+ /* @__PURE__ */ jsx4(
559
849
  "input",
560
850
  {
561
851
  type: "checkbox",
@@ -564,80 +854,93 @@ function MultiChoice({ field, value, error, setValue }) {
564
854
  onChange: () => toggle(opt.id)
565
855
  }
566
856
  ),
567
- /* @__PURE__ */ jsx3("span", { className: "fillo-option-label", children: opt.label })
857
+ /* @__PURE__ */ jsx4(ChoiceIcon, { icon: opt.icon }),
858
+ /* @__PURE__ */ jsx4("span", { className: "fillo-option-label", children: opt.label })
568
859
  ]
569
860
  },
570
861
  opt.id
571
862
  )),
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);
863
+ choice.allowOther && /* @__PURE__ */ jsxs4(
864
+ "label",
865
+ {
866
+ className: `fillo-option fillo-option--other${otherActive ? " fillo-option--selected fillo-option--with-other" : ""}`,
867
+ children: [
868
+ /* @__PURE__ */ jsxs4("span", { className: "fillo-option-main", children: [
869
+ /* @__PURE__ */ jsx4(
870
+ "input",
871
+ {
872
+ type: "checkbox",
873
+ className: "fillo-option-input",
874
+ checked: otherActive,
875
+ onChange: () => {
876
+ if (otherActive) {
877
+ setOtherOn(false);
878
+ setValue(selected.filter((v) => optionIds.has(v)));
879
+ } else {
880
+ setOtherOn(true);
881
+ }
882
+ }
883
+ }
884
+ ),
885
+ /* @__PURE__ */ jsx4("span", { className: "fillo-option-label", children: "Other" })
886
+ ] }),
887
+ otherActive && /* @__PURE__ */ jsx4(
888
+ "input",
889
+ {
890
+ type: "text",
891
+ className: "fillo-input fillo-other-input",
892
+ placeholder: "Your answer",
893
+ autoFocus: otherText === void 0,
894
+ value: otherText ?? "",
895
+ onChange: (e) => {
896
+ const rest = selected.filter((v) => optionIds.has(v));
897
+ setValue(e.target.value ? [...rest, e.target.value] : rest);
898
+ }
585
899
  }
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
- ] })
900
+ )
901
+ ]
902
+ }
903
+ )
605
904
  ]
606
905
  }
607
906
  ) });
608
907
  }
609
908
  var OTHER_SENTINEL = "__fw_other__";
610
- function Dropdown({ field, value, error, setValue }) {
909
+ function Dropdown({ field, value, error, setValue, ids: providedIds }) {
910
+ const ids = useFilloFieldIds(field.id, providedIds);
611
911
  const choice = field;
612
912
  const options = useDisplayOptions(choice);
613
913
  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(
914
+ const [otherOn, setOtherOn] = useState5(isOtherValue);
915
+ const otherActive = isOtherValue || value === "" && otherOn;
916
+ return /* @__PURE__ */ jsxs4(FieldShell, { field, error, ids, children: [
917
+ /* @__PURE__ */ jsxs4("div", { className: "fillo-select-wrap", children: [
918
+ /* @__PURE__ */ jsxs4(
919
+ "select",
920
+ {
921
+ id: ids.inputId,
922
+ className: "fillo-input fillo-select",
923
+ ...fieldAria(field, error, ids),
924
+ value: otherActive ? OTHER_SENTINEL : typeof value === "string" ? value : "",
925
+ onChange: (e) => {
926
+ if (e.target.value === OTHER_SENTINEL) {
927
+ setOtherOn(true);
928
+ setValue("");
929
+ } else {
930
+ setOtherOn(false);
931
+ setValue(e.target.value || null);
932
+ }
933
+ },
934
+ children: [
935
+ /* @__PURE__ */ jsx4("option", { value: "", children: field.placeholder ?? "Choose\u2026" }),
936
+ options.map((opt) => /* @__PURE__ */ jsx4("option", { value: opt.id, children: opt.label }, opt.id)),
937
+ choice.allowOther && /* @__PURE__ */ jsx4("option", { value: OTHER_SENTINEL, children: "Other\u2026" })
938
+ ]
939
+ }
940
+ ),
941
+ /* @__PURE__ */ jsx4("span", { className: "fillo-select-icon", "aria-hidden": "true" })
942
+ ] }),
943
+ otherActive && /* @__PURE__ */ jsx4(
641
944
  "input",
642
945
  {
643
946
  type: "text",
@@ -650,40 +953,76 @@ function Dropdown({ field, value, error, setValue }) {
650
953
  )
651
954
  ] });
652
955
  }
653
- function Checkbox({ field, value, error, setValue }) {
654
- return /* @__PURE__ */ jsxs3(
956
+ function Checkbox({ field, value, error, setValue, ids: providedIds }) {
957
+ const ids = useFilloFieldIds(field.id, providedIds);
958
+ const checkbox = field;
959
+ if (checkbox.appearance === "toggle") {
960
+ return /* @__PURE__ */ jsxs4(
961
+ "div",
962
+ {
963
+ className: `fillo-field fillo-field--checkbox fillo-field--toggle${error ? " fillo-field--error" : ""}`,
964
+ "data-field": field.id,
965
+ children: [
966
+ /* @__PURE__ */ jsxs4("label", { className: "fillo-toggle", children: [
967
+ /* @__PURE__ */ jsx4("span", { className: "fillo-toggle-copy", children: /* @__PURE__ */ jsxs4("span", { className: "fillo-option-label", children: [
968
+ field.label,
969
+ !field.required && /* @__PURE__ */ jsx4("span", { className: "fillo-optional", children: " (optional)" })
970
+ ] }) }),
971
+ /* @__PURE__ */ jsx4(
972
+ "input",
973
+ {
974
+ id: ids.inputId,
975
+ type: "checkbox",
976
+ role: "switch",
977
+ className: "fillo-toggle-input",
978
+ checked: value === true,
979
+ onChange: (e) => setValue(e.target.checked),
980
+ "aria-checked": value === true,
981
+ ...fieldAria(field, error, ids)
982
+ }
983
+ ),
984
+ /* @__PURE__ */ jsx4("span", { className: "fillo-toggle-track", "aria-hidden": "true", children: /* @__PURE__ */ jsx4("span", { className: "fillo-toggle-thumb" }) })
985
+ ] }),
986
+ field.description && /* @__PURE__ */ jsx4("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
987
+ error && /* @__PURE__ */ jsx4("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
988
+ ]
989
+ }
990
+ );
991
+ }
992
+ return /* @__PURE__ */ jsxs4(
655
993
  "div",
656
994
  {
657
995
  className: `fillo-field fillo-field--checkbox${error ? " fillo-field--error" : ""}`,
658
996
  "data-field": field.id,
659
997
  children: [
660
- /* @__PURE__ */ jsxs3("label", { className: "fillo-option", children: [
661
- /* @__PURE__ */ jsx3(
998
+ /* @__PURE__ */ jsxs4("label", { className: "fillo-option", children: [
999
+ /* @__PURE__ */ jsx4(
662
1000
  "input",
663
1001
  {
664
- id: `fillo-${field.id}`,
1002
+ id: ids.inputId,
665
1003
  type: "checkbox",
666
1004
  className: "fillo-option-input",
667
1005
  checked: value === true,
668
1006
  onChange: (e) => setValue(e.target.checked),
669
- ...fieldAria(field, error)
1007
+ ...fieldAria(field, error, ids)
670
1008
  }
671
1009
  ),
672
- /* @__PURE__ */ jsxs3("span", { className: "fillo-option-label", children: [
1010
+ /* @__PURE__ */ jsxs4("span", { className: "fillo-option-label", children: [
673
1011
  field.label,
674
- !field.required && /* @__PURE__ */ jsx3("span", { className: "fillo-optional", children: " (optional)" })
1012
+ !field.required && /* @__PURE__ */ jsx4("span", { className: "fillo-optional", children: " (optional)" })
675
1013
  ] })
676
1014
  ] }),
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 })
1015
+ field.description && /* @__PURE__ */ jsx4("p", { className: "fillo-description", id: ids.descriptionId, children: field.description }),
1016
+ error && /* @__PURE__ */ jsx4("p", { className: "fillo-error", id: ids.errorId, role: "alert", children: error })
679
1017
  ]
680
1018
  }
681
1019
  );
682
1020
  }
683
- function Rating({ field, value, error, setValue }) {
1021
+ function Rating({ field, value, error, setValue, ids: providedIds }) {
1022
+ const ids = useFilloFieldIds(field.id, providedIds);
684
1023
  const max = field.max ?? 5;
685
1024
  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(
1025
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4("div", { className: "fillo-rating", role: "group", "aria-label": field.label, ...fieldAria(field, error, ids), children: Array.from({ length: max }, (_, i) => i + 1).map((n) => /* @__PURE__ */ jsx4(
687
1026
  "button",
688
1027
  {
689
1028
  type: "button",
@@ -696,13 +1035,14 @@ function Rating({ field, value, error, setValue }) {
696
1035
  n
697
1036
  )) }) });
698
1037
  }
699
- function LinearScale({ field, value, error, setValue }) {
1038
+ function LinearScale({ field, value, error, setValue, ids: providedIds }) {
1039
+ const ids = useFilloFieldIds(field.id, providedIds);
700
1040
  const scale = field;
701
1041
  const min = scale.min ?? 1;
702
1042
  const max = scale.max ?? 10;
703
1043
  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(
1044
+ return /* @__PURE__ */ jsxs4(FieldShell, { field, error, ids, children: [
1045
+ /* @__PURE__ */ jsx4("div", { className: "fillo-scale", role: "group", "aria-label": field.label, ...fieldAria(field, error, ids), children: steps.map((n) => /* @__PURE__ */ jsx4(
706
1046
  "button",
707
1047
  {
708
1048
  type: "button",
@@ -713,13 +1053,14 @@ function LinearScale({ field, value, error, setValue }) {
713
1053
  },
714
1054
  n
715
1055
  )) }),
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 })
1056
+ (scale.minLabel || scale.maxLabel) && /* @__PURE__ */ jsxs4("div", { className: "fillo-scale-labels", children: [
1057
+ /* @__PURE__ */ jsx4("span", { children: scale.minLabel }),
1058
+ /* @__PURE__ */ jsx4("span", { children: scale.maxLabel })
719
1059
  ] })
720
1060
  ] });
721
1061
  }
722
- function Ranking({ field, value, error, setValue }) {
1062
+ function Ranking({ field, value, error, setValue, ids: providedIds }) {
1063
+ const ids = useFilloFieldIds(field.id, providedIds);
723
1064
  const ranking = field;
724
1065
  const answered = Array.isArray(value) ? value : [];
725
1066
  const order = [
@@ -734,14 +1075,14 @@ function Ranking({ field, value, error, setValue }) {
734
1075
  [next[index], next[target]] = [next[target], next[index]];
735
1076
  setValue(next);
736
1077
  }
737
- return /* @__PURE__ */ jsx3(FieldShell, { field, error, children: /* @__PURE__ */ jsx3("ol", { className: "fillo-ranking", children: order.map((id, index) => {
1078
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4("ol", { className: "fillo-ranking", children: order.map((id, index) => {
738
1079
  const option = ranking.options.find((o) => o.id === id);
739
1080
  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(
1081
+ return /* @__PURE__ */ jsxs4("li", { className: "fillo-ranking-item", children: [
1082
+ /* @__PURE__ */ jsx4("span", { className: "fillo-ranking-index", children: index + 1 }),
1083
+ /* @__PURE__ */ jsx4("span", { className: "fillo-ranking-label", children: option.label }),
1084
+ /* @__PURE__ */ jsxs4("span", { className: "fillo-ranking-controls", children: [
1085
+ /* @__PURE__ */ jsx4(
745
1086
  "button",
746
1087
  {
747
1088
  type: "button",
@@ -752,7 +1093,7 @@ function Ranking({ field, value, error, setValue }) {
752
1093
  children: "\u2191"
753
1094
  }
754
1095
  ),
755
- /* @__PURE__ */ jsx3(
1096
+ /* @__PURE__ */ jsx4(
756
1097
  "button",
757
1098
  {
758
1099
  type: "button",
@@ -767,22 +1108,23 @@ function Ranking({ field, value, error, setValue }) {
767
1108
  ] }, id);
768
1109
  }) }) });
769
1110
  }
770
- function Matrix({ field, value, error, setValue }) {
1111
+ function Matrix({ field, value, error, setValue, ids: providedIds }) {
1112
+ const ids = useFilloFieldIds(field.id, providedIds);
771
1113
  const matrix = field;
772
1114
  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))
1115
+ return /* @__PURE__ */ jsx4(FieldShell, { field, error, ids, children: /* @__PURE__ */ jsx4("div", { className: "fillo-matrix-wrap", children: /* @__PURE__ */ jsxs4("table", { className: "fillo-matrix", children: [
1116
+ /* @__PURE__ */ jsx4("thead", { children: /* @__PURE__ */ jsxs4("tr", { children: [
1117
+ /* @__PURE__ */ jsx4("th", {}),
1118
+ matrix.columns.map((col) => /* @__PURE__ */ jsx4("th", { scope: "col", children: col.label }, col.id))
777
1119
  ] }) }),
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(
1120
+ /* @__PURE__ */ jsx4("tbody", { children: matrix.rows.map((row) => /* @__PURE__ */ jsxs4("tr", { children: [
1121
+ /* @__PURE__ */ jsx4("th", { scope: "row", children: row.label }),
1122
+ matrix.columns.map((col) => /* @__PURE__ */ jsx4("td", { children: /* @__PURE__ */ jsx4(
781
1123
  "input",
782
1124
  {
783
1125
  type: "radio",
784
1126
  className: "fillo-option-input",
785
- name: `fillo-${field.id}-${row.id}`,
1127
+ name: `${ids.name}-${row.id}`,
786
1128
  "aria-label": `${row.label}: ${col.label}`,
787
1129
  checked: answers[row.id] === col.id,
788
1130
  onChange: () => setValue({ ...answers, [row.id]: col.id })
@@ -798,7 +1140,7 @@ var DEFAULT_COMPONENTS = {
798
1140
  short_text: TextInput,
799
1141
  email: TextInput,
800
1142
  url: TextInput,
801
- phone: TextInput,
1143
+ phone: PhoneField,
802
1144
  number: TextInput,
803
1145
  date: TextInput,
804
1146
  long_text: LongText,
@@ -817,13 +1159,32 @@ var DEFAULT_COMPONENTS = {
817
1159
  function ContentRenderer({ block }) {
818
1160
  switch (block.kind) {
819
1161
  case "heading":
820
- return /* @__PURE__ */ jsx3("h3", { className: "fillo-heading", children: block.text });
1162
+ return /* @__PURE__ */ jsx4("h3", { className: "fillo-heading", children: block.text });
821
1163
  case "paragraph":
822
- return /* @__PURE__ */ jsx3("p", { className: "fillo-paragraph", children: block.text });
1164
+ return /* @__PURE__ */ jsx4("p", { className: "fillo-paragraph", children: block.text });
823
1165
  case "divider":
824
- return /* @__PURE__ */ jsx3("hr", { className: "fillo-divider" });
1166
+ return /* @__PURE__ */ jsx4("hr", { className: "fillo-divider" });
825
1167
  }
826
1168
  }
1169
+ function shouldAutoSubmit(field, value, api) {
1170
+ if (api.form.settings.submitMode !== "auto") return false;
1171
+ if (!api.isLastPage || api.status !== "idle" || api.uploading) return false;
1172
+ const hasDiscreteAnswer = (() => {
1173
+ switch (field.kind) {
1174
+ case "select":
1175
+ case "dropdown":
1176
+ return typeof value === "string" && field.options.some((option) => option.id === value);
1177
+ case "checkbox":
1178
+ return value === true;
1179
+ case "rating":
1180
+ case "linear_scale":
1181
+ return typeof value === "number";
1182
+ default:
1183
+ return false;
1184
+ }
1185
+ })();
1186
+ return hasDiscreteAnswer && validateResponse(api.form, { ...api.data, [field.id]: value }).ok;
1187
+ }
827
1188
  function BlockRenderer({
828
1189
  block,
829
1190
  api,
@@ -831,7 +1192,8 @@ function BlockRenderer({
831
1192
  customComponents
832
1193
  }) {
833
1194
  block = pipeBlock(block, api.data, api.form);
834
- if (!isField(block)) return /* @__PURE__ */ jsx3(ContentRenderer, { block });
1195
+ const ids = useFilloFieldIds(block.id);
1196
+ if (!isField(block)) return /* @__PURE__ */ jsx4(ContentRenderer, { block });
835
1197
  let Component;
836
1198
  if (block.kind === "custom") {
837
1199
  Component = customComponents?.[block.component] ?? components?.custom;
@@ -847,14 +1209,18 @@ function BlockRenderer({
847
1209
  } else {
848
1210
  Component = components?.[block.kind] ?? DEFAULT_COMPONENTS[block.kind];
849
1211
  }
850
- return /* @__PURE__ */ jsx3(
1212
+ return /* @__PURE__ */ jsx4(
851
1213
  Component,
852
1214
  {
853
1215
  field: block,
854
1216
  value: api.data[block.id],
855
1217
  error: api.errors[block.id],
856
- setValue: (v) => api.setValue(block.id, v),
857
- api
1218
+ setValue: (v) => {
1219
+ api.setValue(block.id, v);
1220
+ if (shouldAutoSubmit(block, v, api)) void api.submit();
1221
+ },
1222
+ api,
1223
+ ids
858
1224
  }
859
1225
  );
860
1226
  }
@@ -866,7 +1232,7 @@ function FormField({
866
1232
  const api = useFillo();
867
1233
  const block = api.form.pages.flatMap((p) => p.blocks).find((b) => b.id === id);
868
1234
  if (!block) return null;
869
- return /* @__PURE__ */ jsx3(
1235
+ return /* @__PURE__ */ jsx4(
870
1236
  BlockRenderer,
871
1237
  {
872
1238
  block,
@@ -878,34 +1244,61 @@ function FormField({
878
1244
  }
879
1245
 
880
1246
  // 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;
1247
+ import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1248
+ function themeProps(theme) {
1249
+ const safeTheme = normalizeFormTheme(theme);
1250
+ if (!safeTheme) return {};
884
1251
  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;
1252
+ if (safeTheme.primary) style["--fillo-primary"] = safeTheme.primary;
1253
+ if (safeTheme.background) style["--fillo-bg"] = safeTheme.background;
1254
+ if (safeTheme.text) style["--fillo-text"] = safeTheme.text;
1255
+ if (safeTheme.radius) style["--fillo-radius"] = safeTheme.radius;
1256
+ if (safeTheme.fontFamily) style["--fillo-font"] = safeTheme.fontFamily;
1257
+ const hasColorTheme = Boolean(safeTheme.primary || safeTheme.background || safeTheme.text);
1258
+ return {
1259
+ ...Object.keys(style).length ? { style } : {},
1260
+ ...safeTheme.colorScheme ? { "data-fillo-color-scheme": safeTheme.colorScheme } : {},
1261
+ ...hasColorTheme ? { "data-fillo-has-color-theme": "" } : {}
1262
+ };
1263
+ }
1264
+ function safeHttpUrl(href) {
1265
+ try {
1266
+ if (!href) return null;
1267
+ const url = new URL(href);
1268
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
1269
+ return url.toString();
1270
+ } catch {
1271
+ return null;
1272
+ }
1273
+ }
1274
+ function badgeUrl(href) {
1275
+ const url = new URL(safeHttpUrl(href) ?? "https://fillo.so");
1276
+ url.searchParams.set("utm_source", "form_badge");
1277
+ return url.toString();
1278
+ }
1279
+ function isAutoSubmitBlock(block) {
1280
+ return isField2(block) && (block.kind === "select" || block.kind === "dropdown" || block.kind === "checkbox" || block.kind === "rating" || block.kind === "linear_scale");
1281
+ }
1282
+ function needsExplicitSubmit(blocks) {
1283
+ return blocks.some((block) => isField2(block) && block.kind !== "hidden" && !isAutoSubmitBlock(block));
891
1284
  }
892
1285
  function FilloForm(props) {
893
1286
  const { form, formId } = props;
894
- const client = useMemo(
1287
+ const client = useMemo2(
895
1288
  () => props.client ?? (formId ? createClient() : void 0),
896
1289
  [props.client, formId]
897
1290
  );
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 [fetchedPoweredBy, setFetchedPoweredBy] = useState5(null);
1291
+ const [fetched, setFetched] = useState6(null);
1292
+ const [fetchedTheme, setFetchedTheme] = useState6(null);
1293
+ const [loadError, setLoadError] = useState6(null);
1294
+ const [closed, setClosed] = useState6(false);
1295
+ const [fetchedBranding, setFetchedBranding] = useState6(null);
903
1296
  const codeForm = isCodeForm2(form) ? form : null;
904
1297
  const inlineSchema = codeForm ? codeForm.schema : form;
905
1298
  const onError = props.onError;
906
1299
  const syncedFormId = useCodeFormSync(form, client, onError);
907
1300
  const needsFetch = !inlineSchema && Boolean(client && formId);
908
- useEffect6(() => {
1301
+ useEffect7(() => {
909
1302
  if (!needsFetch || !client || !formId) return;
910
1303
  let cancelled = false;
911
1304
  setFetched(null);
@@ -915,7 +1308,7 @@ function FilloForm(props) {
915
1308
  setFetched(published.schema);
916
1309
  setFetchedTheme(published.theme);
917
1310
  setClosed(Boolean(published.closed));
918
- setFetchedPoweredBy(published.branding?.poweredBy ?? null);
1311
+ setFetchedBranding(published.branding ?? null);
919
1312
  }).catch((err) => {
920
1313
  if (cancelled) return;
921
1314
  const error = err instanceof FilloError2 ? err : new FilloError2("This form could not be loaded.", 0);
@@ -926,35 +1319,42 @@ function FilloForm(props) {
926
1319
  cancelled = true;
927
1320
  };
928
1321
  }, [needsFetch, client, formId, onError]);
929
- const schema = inlineSchema ?? fetched;
1322
+ const rawSchema = inlineSchema ?? fetched;
1323
+ const normalized = useMemo2(() => rawSchema ? normalizeFormSchema(rawSchema) : null, [rawSchema]);
1324
+ const schema = normalized?.ok ? normalized.schema : null;
930
1325
  const theme = props.theme ?? codeForm?.theme ?? fetchedTheme ?? void 0;
1326
+ if (normalized && !normalized.ok) {
1327
+ const error = new FilloError2(`This form could not be rendered: ${normalized.error}`, 422);
1328
+ if (props.renderError) return /* @__PURE__ */ jsx5(Fragment2, { children: props.renderError(error) });
1329
+ return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--error ${props.className ?? ""}`, children: "This form could not be rendered." });
1330
+ }
931
1331
  if (loadError) {
932
1332
  if (props.renderError) {
933
- return /* @__PURE__ */ jsx4(Fragment2, { children: props.renderError(loadError) });
1333
+ return /* @__PURE__ */ jsx5(Fragment2, { children: props.renderError(loadError) });
934
1334
  }
935
1335
  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 });
1336
+ return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--error ${props.className ?? ""}`, children: message });
937
1337
  }
938
1338
  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." }) });
1339
+ 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
1340
  }
941
1341
  if (!schema) {
942
- return /* @__PURE__ */ jsxs4(
1342
+ return /* @__PURE__ */ jsxs5(
943
1343
  "div",
944
1344
  {
945
1345
  className: `fillo-form fillo-form--loading ${props.className ?? ""}`,
946
1346
  "aria-busy": "true",
947
- style: themeStyle(theme),
1347
+ ...themeProps(theme),
948
1348
  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" })
1349
+ /* @__PURE__ */ jsx5("div", { className: "fillo-skeleton" }),
1350
+ /* @__PURE__ */ jsx5("div", { className: "fillo-skeleton" }),
1351
+ /* @__PURE__ */ jsx5("div", { className: "fillo-skeleton fillo-skeleton--short" })
952
1352
  ]
953
1353
  }
954
1354
  );
955
1355
  }
956
- const poweredBy = props.skipValidation ? false : props.poweredBy ?? fetchedPoweredBy ?? true;
957
- return /* @__PURE__ */ jsx4(
1356
+ const poweredBy = fetchedBranding?.poweredBy ?? true;
1357
+ return /* @__PURE__ */ jsx5(
958
1358
  ResolvedForm,
959
1359
  {
960
1360
  ...props,
@@ -962,36 +1362,30 @@ function FilloForm(props) {
962
1362
  schema,
963
1363
  theme,
964
1364
  formId: syncedFormId ?? formId,
965
- poweredBy
1365
+ poweredBy,
1366
+ brandLabel: fetchedBranding?.label,
1367
+ brandHref: fetchedBranding?.href
966
1368
  }
967
1369
  );
968
1370
  }
969
- function PoweredBy() {
970
- return /* @__PURE__ */ jsx4(
971
- "a",
972
- {
973
- className: "fillo-powered",
974
- href: "https://fillo.so?utm_source=form_badge",
975
- target: "_blank",
976
- rel: "noopener",
977
- children: "Powered by Fillo"
978
- }
979
- );
1371
+ function PoweredBy({ label, href }) {
1372
+ return /* @__PURE__ */ jsx5("a", { className: "fillo-powered", href: badgeUrl(href), target: "_blank", rel: "noopener noreferrer", children: label ?? "Powered by Fillo" });
980
1373
  }
981
1374
  function isDevEnv() {
982
1375
  return typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
983
1376
  }
984
1377
  function DevNoClientNotice() {
985
- return /* @__PURE__ */ jsxs4("div", { className: "fillo-devwarning", role: "alert", children: [
986
- /* @__PURE__ */ jsx4("strong", { children: "No Fillo client connected." }),
1378
+ return /* @__PURE__ */ jsxs5("div", { className: "fillo-devwarning", role: "alert", children: [
1379
+ /* @__PURE__ */ jsx5("strong", { children: "No Fillo client connected." }),
987
1380
  " This form is render-only and won\u2019t save responses. Pass a ",
988
- /* @__PURE__ */ jsx4("code", { children: "client" }),
1381
+ /* @__PURE__ */ jsx5("code", { children: "client" }),
989
1382
  " to collect them in Fillo, or forward them with webhooks. (This notice only appears in development.)"
990
1383
  ] });
991
1384
  }
992
1385
  function ResolvedForm(props) {
993
1386
  const { schema, theme } = props;
994
- const hpRef = useRef4("");
1387
+ const instanceId = useId2().replace(/:/g, "");
1388
+ const hpRef = useRef5("");
995
1389
  const api = useFilloController({
996
1390
  form: schema,
997
1391
  formId: props.formId,
@@ -1002,31 +1396,33 @@ function ResolvedForm(props) {
1002
1396
  skipValidation: props.skipValidation,
1003
1397
  getHoneypot: () => hpRef.current
1004
1398
  });
1005
- const style = useMemo(() => themeStyle(theme), [theme]);
1399
+ const themed = useMemo2(() => themeProps(theme), [theme]);
1006
1400
  const settings = schema.settings;
1007
1401
  const submitted = api.status === "submitted";
1008
- useEffect6(() => {
1402
+ useEffect7(() => {
1009
1403
  if (!submitted || !settings.redirectUrl || typeof window === "undefined") return;
1010
- if (/^https?:\/\//i.test(settings.redirectUrl)) {
1011
- window.location.assign(settings.redirectUrl);
1012
- }
1404
+ const url = safeHttpUrl(settings.redirectUrl);
1405
+ if (url) window.location.assign(url);
1013
1406
  }, [submitted, settings.redirectUrl]);
1014
1407
  if (api.status === "submitted") {
1015
- return /* @__PURE__ */ jsxs4("div", { className: `fillo-form fillo-form--success ${props.className ?? ""}`, style, children: [
1016
- props.renderSuccess ? props.renderSuccess() : /* @__PURE__ */ jsxs4("div", { className: "fillo-success", children: [
1017
- /* @__PURE__ */ jsx4("div", { className: "fillo-success-mark", "aria-hidden": "true", children: "\u2713" }),
1018
- /* @__PURE__ */ jsx4("h2", { className: "fillo-success-title", children: settings.successTitle ?? "Thanks!" }),
1019
- /* @__PURE__ */ jsx4("p", { className: "fillo-success-message", children: settings.successMessage ?? "Your response has been recorded." })
1408
+ return /* @__PURE__ */ jsxs5("div", { className: `fillo-form fillo-form--success ${props.className ?? ""}`, ...themed, children: [
1409
+ props.renderSuccess ? props.renderSuccess() : /* @__PURE__ */ jsxs5("div", { className: "fillo-success", children: [
1410
+ /* @__PURE__ */ jsx5("div", { className: "fillo-success-mark", "aria-hidden": "true", children: "\u2713" }),
1411
+ /* @__PURE__ */ jsx5("h2", { className: "fillo-success-title", children: settings.successTitle ?? "Thanks!" }),
1412
+ /* @__PURE__ */ jsx5("p", { className: "fillo-success-message", children: settings.successMessage ?? "Your response has been recorded." })
1020
1413
  ] }),
1021
- props.poweredBy && /* @__PURE__ */ jsx4(PoweredBy, {})
1414
+ props.poweredBy && /* @__PURE__ */ jsx5(PoweredBy, { label: props.brandLabel, href: props.brandHref })
1022
1415
  ] });
1023
1416
  }
1024
1417
  const multiPage = api.pageCount > 1;
1025
- return /* @__PURE__ */ jsx4(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsxs4(
1418
+ const autoSubmit = settings.submitMode === "auto";
1419
+ const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(api.blocks);
1420
+ const showFooter = showPrimaryButton || multiPage && !api.isFirstPage;
1421
+ return /* @__PURE__ */ jsx5(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx5(FilloInstanceIdContext.Provider, { value: instanceId, children: /* @__PURE__ */ jsxs5(
1026
1422
  "form",
1027
1423
  {
1028
1424
  className: `fillo-form ${props.className ?? ""}`,
1029
- style,
1425
+ ...themed,
1030
1426
  noValidate: true,
1031
1427
  onSubmit: (e) => {
1032
1428
  e.preventDefault();
@@ -1034,8 +1430,8 @@ function ResolvedForm(props) {
1034
1430
  else api.next();
1035
1431
  },
1036
1432
  children: [
1037
- !props.client && !props.skipValidation && isDevEnv() && /* @__PURE__ */ jsx4(DevNoClientNotice, {}),
1038
- multiPage && settings.showProgress !== false && /* @__PURE__ */ jsx4(
1433
+ !props.client && !props.skipValidation && isDevEnv() && /* @__PURE__ */ jsx5(DevNoClientNotice, {}),
1434
+ multiPage && settings.showProgress !== false && /* @__PURE__ */ jsx5(
1039
1435
  "div",
1040
1436
  {
1041
1437
  className: "fillo-progress-track",
@@ -1043,7 +1439,7 @@ function ResolvedForm(props) {
1043
1439
  "aria-valuemin": 0,
1044
1440
  "aria-valuemax": api.pageCount,
1045
1441
  "aria-valuenow": api.pageIndex + 1,
1046
- children: /* @__PURE__ */ jsx4(
1442
+ children: /* @__PURE__ */ jsx5(
1047
1443
  "div",
1048
1444
  {
1049
1445
  className: "fillo-progress-fill",
@@ -1052,12 +1448,12 @@ function ResolvedForm(props) {
1052
1448
  )
1053
1449
  }
1054
1450
  ),
1055
- api.pageIndex === 0 && props.showTitle !== false && /* @__PURE__ */ jsxs4("header", { className: "fillo-header", children: [
1056
- /* @__PURE__ */ jsx4("h1", { className: "fillo-title", children: schema.title }),
1057
- schema.description && /* @__PURE__ */ jsx4("p", { className: "fillo-form-description", children: schema.description })
1451
+ api.pageIndex === 0 && props.showTitle !== false && /* @__PURE__ */ jsxs5("header", { className: "fillo-header", children: [
1452
+ /* @__PURE__ */ jsx5("h1", { className: "fillo-title", children: schema.title }),
1453
+ schema.description && /* @__PURE__ */ jsx5("p", { className: "fillo-form-description", children: schema.description })
1058
1454
  ] }),
1059
- multiPage && api.page.title && api.pageIndex > 0 && /* @__PURE__ */ jsx4("h2", { className: "fillo-page-title", children: api.page.title }),
1060
- /* @__PURE__ */ jsx4("div", { className: "fillo-blocks", children: api.blocks.map((block) => /* @__PURE__ */ jsx4(
1455
+ multiPage && api.page.title && api.pageIndex > 0 && /* @__PURE__ */ jsx5("h2", { className: "fillo-page-title", children: api.page.title }),
1456
+ /* @__PURE__ */ jsx5("div", { className: "fillo-blocks", children: api.blocks.map((block) => /* @__PURE__ */ jsx5(
1061
1457
  BlockRenderer,
1062
1458
  {
1063
1459
  block,
@@ -1067,7 +1463,7 @@ function ResolvedForm(props) {
1067
1463
  },
1068
1464
  block.id
1069
1465
  )) }),
1070
- /* @__PURE__ */ jsx4(
1466
+ /* @__PURE__ */ jsx5(
1071
1467
  "input",
1072
1468
  {
1073
1469
  type: "text",
@@ -1082,9 +1478,10 @@ function ResolvedForm(props) {
1082
1478
  }
1083
1479
  }
1084
1480
  ),
1085
- /* @__PURE__ */ jsxs4("footer", { className: "fillo-footer", children: [
1086
- multiPage && !api.isFirstPage && /* @__PURE__ */ jsx4("button", { type: "button", className: "fillo-button fillo-button--ghost", onClick: api.back, children: "Back" }),
1087
- /* @__PURE__ */ jsx4(
1481
+ 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." }),
1482
+ showFooter && /* @__PURE__ */ jsxs5("footer", { className: "fillo-footer", children: [
1483
+ multiPage && !api.isFirstPage && /* @__PURE__ */ jsx5("button", { type: "button", className: "fillo-button fillo-button--ghost", onClick: api.back, children: "Back" }),
1484
+ showPrimaryButton && /* @__PURE__ */ jsx5(
1088
1485
  "button",
1089
1486
  {
1090
1487
  type: "submit",
@@ -1094,17 +1491,26 @@ function ResolvedForm(props) {
1094
1491
  }
1095
1492
  )
1096
1493
  ] }),
1097
- props.poweredBy && /* @__PURE__ */ jsx4(PoweredBy, {})
1494
+ props.poweredBy && /* @__PURE__ */ jsx5(PoweredBy, { label: props.brandLabel, href: props.brandHref })
1098
1495
  ]
1099
1496
  }
1100
- ) });
1497
+ ) }) });
1101
1498
  }
1102
1499
 
1103
1500
  // src/provider.tsx
1104
- import { jsx as jsx5 } from "react/jsx-runtime";
1501
+ import { useId as useId3, useMemo as useMemo3 } from "react";
1502
+ import { FilloError as FilloError3, normalizeFormSchema as normalizeFormSchema2 } from "@usefillo/core";
1503
+ import { jsx as jsx6 } from "react/jsx-runtime";
1105
1504
  function FilloProvider({ children, form, formId, client, ...options }) {
1505
+ const instanceId = useId3().replace(/:/g, "");
1106
1506
  const codeForm = isCodeForm2(form) ? form : null;
1107
- const schema = codeForm ? codeForm.schema : form;
1507
+ const rawSchema = codeForm ? codeForm.schema : form;
1508
+ const schemaKey = JSON.stringify(rawSchema);
1509
+ const normalized = useMemo3(() => normalizeFormSchema2(rawSchema), [rawSchema, schemaKey]);
1510
+ if (!normalized.ok) {
1511
+ throw new FilloError3(`This form could not be rendered: ${normalized.error}`, 422);
1512
+ }
1513
+ const schema = normalized.schema;
1108
1514
  const syncedFormId = useCodeFormSync(form, client);
1109
1515
  const api = useFilloController({
1110
1516
  ...options,
@@ -1115,7 +1521,7 @@ function FilloProvider({ children, form, formId, client, ...options }) {
1115
1521
  // a paid capability the server enforces when these responses are submitted.
1116
1522
  surface: "headless"
1117
1523
  });
1118
- return /* @__PURE__ */ jsx5(FilloContext.Provider, { value: api, children });
1524
+ return /* @__PURE__ */ jsx6(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx6(FilloInstanceIdContext.Provider, { value: instanceId, children }) });
1119
1525
  }
1120
1526
 
1121
1527
  // src/index.ts
@@ -1123,12 +1529,12 @@ import {
1123
1529
  createClient as createClient2,
1124
1530
  provisionWorkspace,
1125
1531
  FilloClient,
1126
- FilloError as FilloError3
1532
+ FilloError as FilloError4
1127
1533
  } from "@usefillo/core";
1128
1534
  export {
1129
1535
  BlockRenderer,
1130
1536
  FilloClient,
1131
- FilloError3 as FilloError,
1537
+ FilloError4 as FilloError,
1132
1538
  FileUploadField as FilloFileUpload,
1133
1539
  FilloForm,
1134
1540
  FilloProvider,