@usefillo/core 0.12.0 → 0.13.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.d.ts CHANGED
@@ -20,7 +20,7 @@ interface JumpRule {
20
20
  /** Target page id, or "end" to finish the form. */
21
21
  to: string | "end";
22
22
  }
23
- type FieldKind = "short_text" | "long_text" | "email" | "url" | "phone" | "number" | "select" | "multi_select" | "dropdown" | "checkbox" | "rating" | "linear_scale" | "ranking" | "matrix" | "signature" | "date" | "file_upload" | "hidden" | "calculated" | "custom";
23
+ type FieldKind = "short_text" | "long_text" | "email" | "url" | "phone" | "number" | "select" | "multi_select" | "dropdown" | "checkbox" | "rating" | "linear_scale" | "ranking" | "matrix" | "signature" | "date" | "file_upload" | "hidden" | "calculated" | "repeating_group" | "custom";
24
24
  type ContentKind = "heading" | "paragraph" | "divider";
25
25
  type BlockKind = FieldKind | ContentKind;
26
26
  interface SelectOption {
@@ -198,6 +198,53 @@ interface CalculatedField extends BaseField {
198
198
  prefix?: string;
199
199
  suffix?: string;
200
200
  }
201
+ /**
202
+ * One repeated instance's answers: childId → that child's canonical value,
203
+ * the exact same shapes a top-level field would carry (trimmed strings,
204
+ * wire-format numerics coerced, empty answers absent — see canonical.ts). A
205
+ * key absent from the record means that child is unanswered in this instance,
206
+ * mirroring how a key absent from {@link ResponseData} means a top-level field
207
+ * is unanswered.
208
+ */
209
+ type GroupInstanceValue = Record<string, FieldValue>;
210
+ /**
211
+ * A repeated block of fields (logic depth P3) — "one row per guest/item",
212
+ * respondent-controlled. Bounded by construction: `maxInstances` is required
213
+ * (1–20); `minInstances` clamps to 0..maxInstances (default 1). The template
214
+ * (`fields`) is capped at 12 children, drawn from a restricted v1 allowlist
215
+ * (see schema-validation's GROUP_CHILD_ALLOWED_KINDS) — notably NOT
216
+ * `repeating_group` itself (nesting is out of scope for v1) and NOT
217
+ * `calculated` (no per-instance calc plumbing yet). Child ids are unique only
218
+ * WITHIN the group (the select-option precedent, not the global block
219
+ * namespace), so the same child id can recur across two different groups.
220
+ *
221
+ * Value shape (locked, roadmap decision 4):
222
+ * `data[groupId]: GroupInstanceValue[]`, instances in respondent order. Key
223
+ * absent ⇔ zero instances (unanswered iff `minInstances` is 0).
224
+ *
225
+ * Scope walls (v1, strict): a child's `visibleIf` may reference ONLY
226
+ * same-group sibling ids, evaluated per instance over that instance's
227
+ * canonical values (see logic.ts's `visibleGroupChildren`). Nothing outside
228
+ * the group — top-level visibility, jumps, calc operands, piping — can
229
+ * reference a child id; both directions are schema hard errors.
230
+ */
231
+ interface RepeatingGroupField extends BaseField {
232
+ kind: "repeating_group";
233
+ /** The repeated template. Child ids are unique WITHIN the group (the
234
+ * select-option precedent, not the global block namespace). */
235
+ fields: Field[];
236
+ /** Respondent-controlled instance count, clamped server-side. 0..maxInstances,
237
+ * default 1. */
238
+ minInstances?: number;
239
+ /** REQUIRED, 1..20 — bounded by design (also bounds the authoring-time
240
+ * worst-case payload-size estimate). */
241
+ maxInstances: number;
242
+ /** "Add another" default via strings. */
243
+ addLabel?: string;
244
+ /** Names one instance — "Guest" → headings "Guest 2 of 3", announcements,
245
+ * CSV column prefixes. Defaults to the field label. */
246
+ itemLabel?: string;
247
+ }
201
248
  /**
202
249
  * A field type Fillo doesn't ship. You define it in code and supply the
203
250
  * renderer via the SDK's `customComponents` map, keyed by `component`. The
@@ -212,7 +259,7 @@ interface CustomField extends BaseField {
212
259
  /** Arbitrary options handed to your component. */
213
260
  config?: Record<string, unknown>;
214
261
  }
215
- type Field = TextField | PhoneField | NumberField | ChoiceField | CheckboxField | RatingField | LinearScaleField | RankingField | MatrixField | SignatureField | DateField | FileUploadField | HiddenField | CalculatedField | CustomField;
262
+ type Field = TextField | PhoneField | NumberField | ChoiceField | CheckboxField | RatingField | LinearScaleField | RankingField | MatrixField | SignatureField | DateField | FileUploadField | HiddenField | CalculatedField | RepeatingGroupField | CustomField;
216
263
  interface HeadingBlock extends BaseBlock {
217
264
  kind: "heading";
218
265
  text: string;
@@ -389,6 +436,10 @@ type JsonValue = string | number | boolean | null | JsonValue[] | {
389
436
  type FieldValue = string | number | boolean | string[] | FileValue[]
390
437
  /** Matrix answers: rowId → columnId. */
391
438
  | Record<string, string>
439
+ /** Repeating-group answers: one record per instance, in respondent order
440
+ * (roadmap decision 4). A NAMED member — never smuggled through
441
+ * JsonValue — so the discriminated union stays exhaustiveness-checkable. */
442
+ | GroupInstanceValue[]
392
443
  /** Custom fields hold arbitrary JSON. */
393
444
  | JsonValue | null | undefined;
394
445
  type ResponseData = Record<string, FieldValue>;
@@ -491,6 +542,27 @@ declare function allFields(form: FormSchema): Field[];
491
542
  declare function responseScopeValue(settings: FormSettings, data: ResponseData): string | null;
492
543
  /** Fields currently visible given the response data. */
493
544
  declare function visibleFields(form: FormSchema, data: ResponseData): Field[];
545
+ /**
546
+ * THE scoped per-instance visibility resolver for a repeating group's
547
+ * children (contract decisions 3 + 4): given ONE instance's values, the
548
+ * template children currently visible in that instance, in template order.
549
+ * Both renderers (which rows to draw in instance n) and validateResponse
550
+ * (which children to require/keep in instance n) consume THIS one helper —
551
+ * no second engine. It literally IS the shared engine: the children run
552
+ * through the same {@link resolveLogicState} fixpoint a page of top-level
553
+ * fields would, over the instance's values — so canonical-value discipline
554
+ * (trim/coerce/empty-drop before any condition reads a sibling) and
555
+ * hidden-sibling cascading (a hidden controller reads as unanswered) behave
556
+ * exactly like top level, one scope down. Scope walls are enforced at schema
557
+ * time: a child's visibleIf can only name same-group siblings, so the
558
+ * instance's own values are the complete condition universe — outer answers
559
+ * can never matter here, and child values never leak out (the group is one
560
+ * opaque field in the top-level fixpoint). No calc pass runs because
561
+ * `calculated` children are schema-rejected (decision 2). A logic-hidden
562
+ * child is unanswered for its siblings AND skipped by per-instance
563
+ * validation, mirroring the top-level discipline.
564
+ */
565
+ declare function visibleGroupChildren(group: RepeatingGroupField, instanceValues: GroupInstanceValue): Field[];
494
566
  /** Where navigation goes when leaving a page. */
495
567
  type NextPage = {
496
568
  to: string;
@@ -637,6 +709,16 @@ declare const FILLO_CHALLENGE_MIN_SDK_VERSION = "0.9.0";
637
709
  * applicable floors).
638
710
  */
639
711
  declare const FILLO_CALC_MIN_SDK_VERSION = "0.11.0";
712
+ /**
713
+ * The floor served INSTEAD of FILLO_MIN_SDK_VERSION for forms whose live
714
+ * schema contains a repeating_group field: the first release that ships the
715
+ * kind. Stricter than the calc case (recon risk 5): an old SDK's zod enum
716
+ * doesn't just strip one row, it drops the WHOLE templated section silently —
717
+ * worse than a blank row, because there is no visual trace a question ever
718
+ * existed. Non-negotiable; the form GET picks the max of the applicable
719
+ * floors (the Turnstile/calc precedent).
720
+ */
721
+ declare const FILLO_GROUP_MIN_SDK_VERSION = "0.13.0";
640
722
  declare function normalizeSettings(value: unknown): FormSettings;
641
723
  interface SchemaValidationResult {
642
724
  ok: boolean;
@@ -899,6 +981,14 @@ declare function createEmptyForm(title?: string): FormSchema;
899
981
  * first two are code-only concerns and signature is too niche to draft well)
900
982
  * and calculated (an LLM can't reliably wire a calc AST to stable field ids —
901
983
  * owners add calculations deliberately, in the builder or in code).
984
+ * Also excludes repeating_group (contract decision 11): the flat FieldSpec →
985
+ * createBlock-defaults assembly has no nested-template channel, and a group
986
+ * needs structural choices an LLM gets wrong in ways the assembler can't
987
+ * repair — per-group-unique child ids, the v1 child-kind allowlist, and a
988
+ * deliberate maxInstances bound (required, 1–20). A malformed group is a
989
+ * schema HARD error, so a hallucinated one would brick the whole draft rather
990
+ * than degrade a field. Owners add groups deliberately, in the builder or in
991
+ * code — the calculated precedent.
902
992
  */
903
993
  declare const DRAFT_KINDS: readonly BlockKind[];
904
994
  /** The LLM-friendly intermediate shape. Everything optional but `kind`. */
@@ -1696,8 +1786,8 @@ declare function resolveThemeAppearance(theme: FormTheme | null): FormTheme | nu
1696
1786
  /**
1697
1787
  * The default English validation message for a required field left empty.
1698
1788
  * `validateField` returns this exact sentinel (core has no strings context),
1699
- * and the React render layer swaps it for `strings.required` so a localized
1700
- * site can translate it. Keep it identical to `DEFAULT_FIELD_STRINGS.required`.
1789
+ * and renderer layers swap it for field-aware/localized copy. Keep it
1790
+ * identical to `DEFAULT_FIELD_STRINGS.required`.
1701
1791
  */
1702
1792
  declare const REQUIRED_FIELD_MESSAGE = "This field is required";
1703
1793
  /**
@@ -1763,8 +1853,12 @@ declare const DEFAULT_STRINGS: FilloStrings;
1763
1853
  * an exhaustively-enumerated, closed set.
1764
1854
  */
1765
1855
  interface FilloFieldStrings {
1766
- /** Validation: a required field was left empty. Mirrors REQUIRED_FIELD_MESSAGE. */
1856
+ /** Generic/legacy validation copy for a required field left empty. Mirrors
1857
+ * REQUIRED_FIELD_MESSAGE and remains the fallback for custom/hidden fields. */
1767
1858
  required: string;
1859
+ /** Optional field-aware required copy. Existing `required` overrides keep
1860
+ * their legacy generic behavior unless this function is also supplied. */
1861
+ requiredForField?: (field: Field) => string;
1768
1862
  /** Notice when a spent/expired resume link couldn't restore progress. */
1769
1863
  resumeLinkExpired: string;
1770
1864
  /** Success-screen message when a verified identity re-submits and the form
@@ -1772,6 +1866,14 @@ interface FilloFieldStrings {
1772
1866
  alreadyAnswered: string;
1773
1867
  /** Retry control on a failed upload row. */
1774
1868
  uploadRetry: string;
1869
+ /** Accessible action names for upload rows. The filename is appended by the renderer. */
1870
+ uploadCancel: string;
1871
+ uploadRemove: string;
1872
+ uploadDismiss: string;
1873
+ /** Visible secondary status for an upload in progress. */
1874
+ uploadingFile: (percent: number, size: string) => string;
1875
+ /** Visible secondary status for a completed upload. */
1876
+ uploadedFile: (size: string) => string;
1775
1877
  /** Dropzone copy when uploads can't run because the form isn't connected. */
1776
1878
  uploadsDisabled: string;
1777
1879
  /** Dropzone copy for an explicit render-only preview, which never has transport. */
@@ -1797,11 +1899,21 @@ interface FilloFieldStrings {
1797
1899
  /** Live-region announcement while a submit is in flight — for auto-submit
1798
1900
  * forms, which have no footer/button to show `submitting` on. */
1799
1901
  submittingAnnouncement: string;
1800
- /** Heading on the failed-submit error summary (one `role="alert"` summary
1801
- * linking to fields, GOV.UK pattern — replaces N competing per-field alerts). */
1902
+ /** @deprecated Retained for localization compatibility. Default renderers
1903
+ * now focus the first invalid control and show field-aware inline guidance. */
1802
1904
  errorSummaryTitle: string;
1803
1905
  /** Live-region announcement after a ranking move: "«label», position n of m". */
1804
1906
  rankingPosition: (label: string, position: number, count: number) => string;
1907
+ /** Repeating group: the Add button's default label. */
1908
+ groupAdd: string;
1909
+ /** Repeating group instance heading — "«item» n of m". */
1910
+ groupInstanceLabel: (item: string, position: number, count: number) => string;
1911
+ /** Repeating group per-instance Remove button accessible label. */
1912
+ groupRemoveLabel: (item: string, position: number) => string;
1913
+ /** Live-region announcement after adding an instance. */
1914
+ groupInstanceAdded: (item: string, position: number, count: number) => string;
1915
+ /** Live-region announcement after removing an instance. */
1916
+ groupInstanceRemoved: (item: string, count: number) => string;
1805
1917
  /** Live-region announcement once a phone country-picker selection commits
1806
1918
  * (focus moves straight to the national input, so nothing else announces it). */
1807
1919
  phoneCountrySelected: (name: string) => string;
@@ -1816,8 +1928,12 @@ interface FilloFieldStrings {
1816
1928
  declare const DEFAULT_FIELD_STRINGS: FilloFieldStrings;
1817
1929
  /** Everything the default renderers can localize — chrome + field/validation. */
1818
1930
  type FilloRendererStrings = FilloStrings & FilloFieldStrings;
1931
+ /** Resolve the renderer copy for core's stable required-field sentinel. */
1932
+ declare function requiredFieldMessage(field: Field, strings: Pick<FilloFieldStrings, "required" | "requiredForField">): string;
1819
1933
  /** Merge overrides over the built-in chrome + field defaults. Accepts a partial
1820
- * of the full renderer surface so the `strings` prop can override either set. */
1934
+ * of the full renderer surface so the `strings` prop can override either set.
1935
+ * A legacy generic `required` override disables the new field-aware default
1936
+ * unless the caller explicitly supplies `requiredForField` too. */
1821
1937
  declare function resolveStrings(overrides?: Partial<FilloRendererStrings>): FilloRendererStrings;
1822
1938
 
1823
1939
  /** Result of syncing a code-defined form: its canonical id, slug, and branding. */
@@ -1938,6 +2054,11 @@ interface BlockSpec {
1938
2054
  optionChildren?: boolean;
1939
2055
  /** String children become the `text` prop (heading/paragraph). */
1940
2056
  textChildren?: boolean;
2057
+ /** Accepts Fillo FIELD elements as children, compiled into the `fields`
2058
+ * template (repeating groups). The walk's flatten/classify/buildProps trio
2059
+ * is reentrant, so children build through the exact same path as top-level
2060
+ * blocks. Child ids are per-GROUP (checked here), not global. */
2061
+ blockChildren?: boolean;
1941
2062
  /** Content blocks carry no label/required/etc. */
1942
2063
  content?: boolean;
1943
2064
  }
@@ -2058,4 +2179,4 @@ declare class Sha1 {
2058
2179
  }
2059
2180
  declare const sha1Base64: (bytes: Uint8Array) => string;
2060
2181
 
2061
- export { type AutoSubmitContext, BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type CalcExpr, type CalculatedField, type ChallengeConfig, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CreatedDraft, type CustomField, DEFAULT_FIELD_STRINGS, DEFAULT_STRINGS, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_CALC_MIN_SDK_VERSION, FILLO_CHALLENGE_MIN_SDK_VERSION, FILLO_DATA_ATTRS, FILLO_MIN_SDK_VERSION, FILLO_SCHEMA_VERSION, FILLO_SDK_VERSION, FILLO_SLOTS, FILLO_THEME_VARS, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, type FilloAppearance, FilloClient, type FilloClientOptions, FilloError, type FilloFieldStrings, FilloJsxError, type FilloRendererStrings, type FilloRespondent, type FilloSlot, type FilloStrings, type FormBranding, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, JSX_BLOCK_COMPONENTS, JSX_BLOCK_SPECS, type JsonValue, type JsxFormMeta, type JumpRule, type LinearScaleField, type MatrixField, type NextPage, type NumberField, PHONE_COUNTRIES, PHONE_PICKER_COUNTRIES, PHONE_POPOVER_VIEWPORT_GAP, type ParagraphBlock, type ParsePhoneOptions, type ParsedPhone, type PhoneCountry, type PhoneField, type PhonePopoverPlacement, type ProvisionWorkspaceResult, type PublishedForm, REQUIRED_FIELD_MESSAGE, type RankingField, type RatingField, type ResponseData, type ResponseDraft, type ResponseLimit, type SchemaValidationResult, type SelectOption, Sha1, type SignatureField, type SlotClass, type SlotState, type SubmitMeta, type SubmitResult, type SyncFormResult, type SyncedForm, type TextField, type TrustPolicy, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, type WhenBuilder, allFields, assembleForm, codeFormFromJsx, computeCalculated, conditionsMet, contentHash, countryByDialCode, countryByIso, countryByTimeZone, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, digitsOnly, evaluateCalc, flagEmoji, formSchemasEqual, formatAnswer, formatGroupedNumber, formatNational, isAutoSubmitBlock, isBlockVisible, isBuildTimeDevEnv, isCodeForm, isField, isFilloError, isLikelyDevEnv, isPossiblePhone, isTerminalPage, isValidPartialNumberText, localeForNotation, needsExplicitSubmit, normalizeFormSchema, normalizeFormTheme, normalizeSettings, parseGroupedNumber, parsePhone, pipeBlock, positionPhonePopover, prefillFromParams, provisionWorkspace, radioGroupStep, reachableFieldIds, reachableFields, reachablePageIds, reachablePageSequence, resolveNextPage, resolveSlotClass, resolveStrings, resolveText, resolveThemeAppearance, responseScopeValue, schemaFromJsx, sha1Base64, shouldAutoSubmit, slotClass, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields, visiblePageBlocks, when };
2182
+ export { type AutoSubmitContext, BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type CalcExpr, type CalculatedField, type ChallengeConfig, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CreatedDraft, type CustomField, DEFAULT_FIELD_STRINGS, DEFAULT_STRINGS, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_CALC_MIN_SDK_VERSION, FILLO_CHALLENGE_MIN_SDK_VERSION, FILLO_DATA_ATTRS, FILLO_GROUP_MIN_SDK_VERSION, FILLO_MIN_SDK_VERSION, FILLO_SCHEMA_VERSION, FILLO_SDK_VERSION, FILLO_SLOTS, FILLO_THEME_VARS, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, type FilloAppearance, FilloClient, type FilloClientOptions, FilloError, type FilloFieldStrings, FilloJsxError, type FilloRendererStrings, type FilloRespondent, type FilloSlot, type FilloStrings, type FormBranding, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type GroupInstanceValue, type HeadingBlock, type HiddenField, JSX_BLOCK_COMPONENTS, JSX_BLOCK_SPECS, type JsonValue, type JsxFormMeta, type JumpRule, type LinearScaleField, type MatrixField, type NextPage, type NumberField, PHONE_COUNTRIES, PHONE_PICKER_COUNTRIES, PHONE_POPOVER_VIEWPORT_GAP, type ParagraphBlock, type ParsePhoneOptions, type ParsedPhone, type PhoneCountry, type PhoneField, type PhonePopoverPlacement, type ProvisionWorkspaceResult, type PublishedForm, REQUIRED_FIELD_MESSAGE, type RankingField, type RatingField, type RepeatingGroupField, type ResponseData, type ResponseDraft, type ResponseLimit, type SchemaValidationResult, type SelectOption, Sha1, type SignatureField, type SlotClass, type SlotState, type SubmitMeta, type SubmitResult, type SyncFormResult, type SyncedForm, type TextField, type TrustPolicy, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, type WhenBuilder, allFields, assembleForm, codeFormFromJsx, computeCalculated, conditionsMet, contentHash, countryByDialCode, countryByIso, countryByTimeZone, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, digitsOnly, evaluateCalc, flagEmoji, formSchemasEqual, formatAnswer, formatGroupedNumber, formatNational, isAutoSubmitBlock, isBlockVisible, isBuildTimeDevEnv, isCodeForm, isField, isFilloError, isLikelyDevEnv, isPossiblePhone, isTerminalPage, isValidPartialNumberText, localeForNotation, needsExplicitSubmit, normalizeFormSchema, normalizeFormTheme, normalizeSettings, parseGroupedNumber, parsePhone, pipeBlock, positionPhonePopover, prefillFromParams, provisionWorkspace, radioGroupStep, reachableFieldIds, reachableFields, reachablePageIds, reachablePageSequence, requiredFieldMessage, resolveNextPage, resolveSlotClass, resolveStrings, resolveText, resolveThemeAppearance, responseScopeValue, schemaFromJsx, sha1Base64, shouldAutoSubmit, slotClass, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields, visibleGroupChildren, visiblePageBlocks, when };
package/dist/index.js CHANGED
@@ -13,9 +13,38 @@ function isEmpty(value) {
13
13
  if (typeof value === "object") return Object.keys(value).length === 0;
14
14
  return false;
15
15
  }
16
+ function normalizeGroupValue(field2, value) {
17
+ if (!Array.isArray(value)) return void 0;
18
+ const childById = new Map(field2.fields.map((child) => [child.id, child]));
19
+ let changed = false;
20
+ const instances = value.map((instance) => {
21
+ if (!instance || typeof instance !== "object" || Array.isArray(instance)) return instance;
22
+ let instanceChanged = false;
23
+ const out = {};
24
+ for (const [key, raw] of Object.entries(instance)) {
25
+ const child = childById.get(key);
26
+ if (!child) {
27
+ instanceChanged = true;
28
+ continue;
29
+ }
30
+ const canonical = canonicalValue(child, raw);
31
+ if (canonical === void 0) {
32
+ instanceChanged = true;
33
+ continue;
34
+ }
35
+ out[key] = canonical;
36
+ if (canonical !== raw) instanceChanged = true;
37
+ }
38
+ if (!instanceChanged) return instance;
39
+ changed = true;
40
+ return out;
41
+ });
42
+ return changed ? instances : value;
43
+ }
16
44
  function normalizeValue(field2, value) {
17
45
  if (value === void 0 || value === null) return value;
18
46
  if (field2.kind === "custom") return value;
47
+ if (field2.kind === "repeating_group") return normalizeGroupValue(field2, value);
19
48
  if (field2.kind === "number" && typeof value === "string" && value.trim() !== "") {
20
49
  const n = Number(value);
21
50
  return Number.isNaN(n) ? value : n;
@@ -190,7 +219,12 @@ function resolveLogicState(fields, data) {
190
219
  for (const f of fields) {
191
220
  if (f.kind === "calculated") continue;
192
221
  const value = canonicalValue(f, data[f.id]);
193
- if (value !== void 0) canonical[f.id] = value;
222
+ if (value === void 0) continue;
223
+ if (f.kind === "repeating_group") {
224
+ const count = Array.isArray(value) ? value.length : 0;
225
+ if (count < Math.max(1, f.minInstances ?? 1)) continue;
226
+ }
227
+ canonical[f.id] = value;
194
228
  }
195
229
  let visible = scoped;
196
230
  let calc = {};
@@ -257,6 +291,11 @@ function visibleFields(form, data) {
257
291
  const visible = visibleFieldIds(fields, data);
258
292
  return fields.filter((f) => visible.has(f.id));
259
293
  }
294
+ function visibleGroupChildren(group, instanceValues) {
295
+ const values = instanceValues && typeof instanceValues === "object" && !Array.isArray(instanceValues) ? instanceValues : {};
296
+ const state = resolveLogicState(group.fields, values);
297
+ return group.fields.filter((f) => state.visible.has(f.id));
298
+ }
260
299
  function formResolver(form, data) {
261
300
  return resolveLogicState(allFields(form), data).resolve;
262
301
  }
@@ -707,11 +746,39 @@ var DEFAULT_STRINGS = {
707
746
  resumeStartOver: "Start over",
708
747
  challengeUnavailable: "The verification check couldn't load. Refresh the page, or check that challenges.cloudflare.com isn't blocked."
709
748
  };
749
+ var REQUIRED_BY_KIND = {
750
+ short_text: "Enter your answer",
751
+ long_text: "Enter your answer",
752
+ email: "Enter an email address",
753
+ url: "Enter a URL",
754
+ phone: "Enter a phone number",
755
+ number: "Enter a number",
756
+ select: "Choose an option",
757
+ dropdown: "Choose an option",
758
+ multi_select: "Select at least one option",
759
+ checkbox: "Select this checkbox",
760
+ rating: "Choose a rating",
761
+ linear_scale: "Choose a value",
762
+ ranking: "Rank every option",
763
+ matrix: "Answer every row",
764
+ signature: "Add your signature",
765
+ date: "Enter a date",
766
+ file_upload: "Add a file"
767
+ };
768
+ function defaultRequiredForField(field2) {
769
+ return REQUIRED_BY_KIND[field2.kind] ?? REQUIRED_FIELD_MESSAGE;
770
+ }
710
771
  var DEFAULT_FIELD_STRINGS = {
711
772
  required: REQUIRED_FIELD_MESSAGE,
773
+ requiredForField: defaultRequiredForField,
712
774
  resumeLinkExpired: "That saved-progress link expired or was already used \u2014 start again below.",
713
775
  alreadyAnswered: "You've already answered this form.",
714
776
  uploadRetry: "Retry",
777
+ uploadCancel: "Cancel",
778
+ uploadRemove: "Remove",
779
+ uploadDismiss: "Dismiss",
780
+ uploadingFile: (percent, size) => `Uploading \xB7 ${percent}% \xB7 ${size}`,
781
+ uploadedFile: (size) => `Uploaded \xB7 ${size}`,
715
782
  uploadsDisabled: "Connect this form to Fillo to enable uploads",
716
783
  uploadsRenderOnly: "Uploads are unavailable in this render-only preview",
717
784
  uploadsUnavailable: "Uploads are temporarily unavailable",
@@ -724,15 +791,27 @@ var DEFAULT_FIELD_STRINGS = {
724
791
  uploadsFailed: (count) => `${count} ${count === 1 ? "upload" : "uploads"} failed.`,
725
792
  filesUploaded: (count) => `${count} ${count === 1 ? "file" : "files"} uploaded.`,
726
793
  submittingAnnouncement: "Submitting your response\u2026",
727
- errorSummaryTitle: "Please fix the following:",
794
+ errorSummaryTitle: "Check these fields",
728
795
  rankingPosition: (label, position, count) => `${label}, position ${position} of ${count}`,
796
+ groupAdd: "Add another",
797
+ groupInstanceLabel: (item, position, count) => `${item} ${position} of ${count}`,
798
+ groupRemoveLabel: (item, position) => `Remove ${item} ${position}`,
799
+ groupInstanceAdded: (item, position, count) => `${item} ${position} added, ${position} of ${count}`,
800
+ groupInstanceRemoved: (item, count) => count === 0 ? `${item} removed, none left` : `${item} removed, ${count} remaining`,
729
801
  phoneCountrySelected: (name) => `${name} selected`,
730
802
  phoneResultsCount: (count) => `${count} ${count === 1 ? "result" : "results"}`,
731
803
  signatureEmpty: "No signature yet",
732
804
  signatureSigned: "Signature saved"
733
805
  };
806
+ function requiredFieldMessage(field2, strings) {
807
+ return strings.requiredForField?.(field2) ?? strings.required;
808
+ }
734
809
  function resolveStrings(overrides) {
735
- return overrides ? { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS, ...overrides } : { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS };
810
+ const resolved = overrides ? { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS, ...overrides } : { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS };
811
+ if (overrides && Object.prototype.hasOwnProperty.call(overrides, "required") && !Object.prototype.hasOwnProperty.call(overrides, "requiredForField")) {
812
+ delete resolved.requiredForField;
813
+ }
814
+ return resolved;
736
815
  }
737
816
 
738
817
  // src/validation.ts
@@ -743,9 +822,17 @@ var fileValueSchema = z.object({
743
822
  mime: z.string(),
744
823
  url: z.optional(z.string())
745
824
  });
825
+ function groupCountError(field2, count) {
826
+ const min = Math.max(0, field2.minInstances ?? 1);
827
+ if (count < min) return `Add at least ${min} ${min === 1 ? "entry" : "entries"}`;
828
+ if (Number.isFinite(field2.maxInstances) && count > field2.maxInstances)
829
+ return `At most ${field2.maxInstances} ${field2.maxInstances === 1 ? "entry" : "entries"}`;
830
+ return null;
831
+ }
746
832
  function validateField(field2, value) {
747
833
  if (isEmpty(value)) {
748
834
  if (field2.kind === "calculated") return null;
835
+ if (field2.kind === "repeating_group") return groupCountError(field2, 0);
749
836
  return field2.required ? REQUIRED_FIELD_MESSAGE : null;
750
837
  }
751
838
  switch (field2.kind) {
@@ -865,6 +952,17 @@ function validateField(field2, value) {
865
952
  case "calculated": {
866
953
  return null;
867
954
  }
955
+ case "repeating_group": {
956
+ if (!Array.isArray(value)) {
957
+ return groupCountError(field2, 0);
958
+ }
959
+ const instances = value;
960
+ for (const instance of instances) {
961
+ if (!instance || typeof instance !== "object" || Array.isArray(instance))
962
+ return "Invalid value";
963
+ }
964
+ return groupCountError(field2, instances.length);
965
+ }
868
966
  case "custom": {
869
967
  return null;
870
968
  }
@@ -894,6 +992,10 @@ function validateResponse(form, data) {
894
992
  for (const field2 of fields) {
895
993
  if (field2.kind === "calculated") continue;
896
994
  const value = normalizeValue(field2, data[field2.id]);
995
+ if (field2.kind === "repeating_group") {
996
+ validateGroupResponse(field2, value, errors, cleaned);
997
+ continue;
998
+ }
897
999
  const error = validateField(field2, value);
898
1000
  if (error) {
899
1001
  errors[field2.id] = error;
@@ -903,6 +1005,29 @@ function validateResponse(form, data) {
903
1005
  }
904
1006
  return { ok: Object.keys(errors).length === 0, errors, data: cleaned };
905
1007
  }
1008
+ function validateGroupResponse(field2, value, errors, cleaned) {
1009
+ const groupError = validateField(field2, value);
1010
+ if (groupError) errors[field2.id] = groupError;
1011
+ if (!Array.isArray(value)) return;
1012
+ const instances = value;
1013
+ const kept = [];
1014
+ for (let index = 0; index < instances.length; index++) {
1015
+ const instance = instances[index];
1016
+ if (!instance || typeof instance !== "object" || Array.isArray(instance)) continue;
1017
+ const keptInstance = {};
1018
+ for (const child of visibleGroupChildren(field2, instance)) {
1019
+ const childValue = instance[child.id];
1020
+ const childError = validateField(child, childValue);
1021
+ if (childError) {
1022
+ errors[`${field2.id}.${index}.${child.id}`] = childError;
1023
+ } else if (!isEmpty(childValue)) {
1024
+ keptInstance[child.id] = childValue;
1025
+ }
1026
+ }
1027
+ kept.push(keptInstance);
1028
+ }
1029
+ if (!groupError && kept.length > 0) cleaned[field2.id] = kept;
1030
+ }
906
1031
 
907
1032
  // src/schema-validation.ts
908
1033
  import * as z2 from "zod/mini";
@@ -926,6 +1051,7 @@ var FIELD_KINDS = [
926
1051
  "file_upload",
927
1052
  "hidden",
928
1053
  "calculated",
1054
+ "repeating_group",
929
1055
  "custom"
930
1056
  ];
931
1057
  var CONTENT_KINDS2 = ["heading", "paragraph", "divider"];
@@ -962,10 +1088,11 @@ var schemaShape = z2.object({
962
1088
  });
963
1089
  var MAX_SCHEMA_VERSION = 1;
964
1090
  var FILLO_SCHEMA_VERSION = 1;
965
- var FILLO_SDK_VERSION = true ? "0.12.0" : "0.0.0-dev";
1091
+ var FILLO_SDK_VERSION = true ? "0.13.0" : "0.0.0-dev";
966
1092
  var FILLO_MIN_SDK_VERSION = "0.4.0";
967
1093
  var FILLO_CHALLENGE_MIN_SDK_VERSION = "0.9.0";
968
1094
  var FILLO_CALC_MIN_SDK_VERSION = "0.11.0";
1095
+ var FILLO_GROUP_MIN_SDK_VERSION = "0.13.0";
969
1096
  function str(value, max, fallback = "") {
970
1097
  return typeof value === "string" ? value.trim().slice(0, max) : fallback;
971
1098
  }
@@ -1135,7 +1262,7 @@ function collectCalcRefs(expr, valueRefs, whenRefs) {
1135
1262
  return;
1136
1263
  }
1137
1264
  }
1138
- function validateCalculatedFields(pages) {
1265
+ function validateCalculatedFields(pages, childToGroup) {
1139
1266
  const kinds = /* @__PURE__ */ new Map();
1140
1267
  for (const page of pages) {
1141
1268
  for (const block of page.blocks) if (isField(block)) kinds.set(block.id, block.kind);
@@ -1155,6 +1282,10 @@ function validateCalculatedFields(pages) {
1155
1282
  for (const ref of valueRefs) {
1156
1283
  const kind = kinds.get(ref);
1157
1284
  if (kind === void 0) {
1285
+ const owningGroup = childToGroup.get(ref);
1286
+ if (owningGroup !== void 0) {
1287
+ return `Calculated field ${block.id} references ${ref}, which is a child of repeating group ${owningGroup} \u2014 calculations can't reach inside a repeating group (calc operands must be top-level fields)`;
1288
+ }
1158
1289
  return `Calculated field ${block.id} references a missing field: ${ref} \u2014 remove the reference or restore that field`;
1159
1290
  }
1160
1291
  if (!CALC_OPERAND_KINDS.includes(kind)) {
@@ -1228,6 +1359,151 @@ function calcComplexityError(pages) {
1228
1359
  }
1229
1360
  return null;
1230
1361
  }
1362
+ var GROUP_CHILD_ALLOWED_KINDS = [
1363
+ "short_text",
1364
+ "long_text",
1365
+ "email",
1366
+ "url",
1367
+ "phone",
1368
+ "number",
1369
+ "select",
1370
+ "multi_select",
1371
+ "dropdown",
1372
+ "checkbox",
1373
+ "date",
1374
+ "rating",
1375
+ "linear_scale"
1376
+ ];
1377
+ var MAX_GROUP_TEMPLATE_CHILDREN = 12;
1378
+ var MAX_GROUP_ESTIMATED_BYTES = 18e4;
1379
+ function worstCaseChildBytes(child) {
1380
+ const overhead = child.id.length + 20;
1381
+ switch (child.kind) {
1382
+ case "short_text":
1383
+ case "email":
1384
+ case "url":
1385
+ return overhead + (child.maxLength ?? 2e3);
1386
+ case "long_text":
1387
+ return overhead + (child.maxLength ?? 2e4);
1388
+ case "phone":
1389
+ return overhead + 32;
1390
+ case "number":
1391
+ return overhead + 32;
1392
+ case "select":
1393
+ case "dropdown": {
1394
+ const longestOption = child.options.reduce((max, o) => Math.max(max, o.id.length), 0);
1395
+ return overhead + Math.max(longestOption, child.allowOther ? 500 : 0);
1396
+ }
1397
+ case "multi_select": {
1398
+ const everyOption = child.options.reduce((sum, o) => sum + o.id.length + 3, 0);
1399
+ return overhead + everyOption + (child.allowOther ? 500 : 0);
1400
+ }
1401
+ case "checkbox":
1402
+ return overhead + 8;
1403
+ case "date":
1404
+ return overhead + 16;
1405
+ case "rating":
1406
+ case "linear_scale":
1407
+ return overhead + 8;
1408
+ default:
1409
+ return overhead + 64;
1410
+ }
1411
+ }
1412
+ function groupSizeEstimate(field2) {
1413
+ const perInstance = field2.fields.reduce((sum, child) => sum + worstCaseChildBytes(child), 24);
1414
+ return perInstance * (Number.isFinite(field2.maxInstances) ? field2.maxInstances : 0);
1415
+ }
1416
+ function validateRepeatingGroups(pages) {
1417
+ for (const page of pages) {
1418
+ for (const block of page.blocks) {
1419
+ if (block.kind !== "repeating_group") continue;
1420
+ if (!Number.isInteger(block.maxInstances) || block.maxInstances < 1 || block.maxInstances > 20) {
1421
+ return `Repeating group ${block.id} needs maxInstances to be a whole number between 1 and 20`;
1422
+ }
1423
+ const minInstances = block.minInstances ?? 1;
1424
+ if (!Number.isInteger(minInstances) || minInstances < 0 || minInstances > block.maxInstances) {
1425
+ return `Repeating group ${block.id} needs minInstances to be a whole number between 0 and maxInstances (${block.maxInstances})`;
1426
+ }
1427
+ block.minInstances = minInstances;
1428
+ if (block.fields.length === 0) {
1429
+ return `Repeating group ${block.id} has no template fields \u2014 add at least one field to repeat`;
1430
+ }
1431
+ if (block.fields.length > MAX_GROUP_TEMPLATE_CHILDREN) {
1432
+ return `Repeating group ${block.id} has ${block.fields.length} template fields \u2014 the maximum is ${MAX_GROUP_TEMPLATE_CHILDREN}; split it into fewer fields`;
1433
+ }
1434
+ for (const child of block.fields) {
1435
+ if (!GROUP_CHILD_ALLOWED_KINDS.includes(child.kind)) {
1436
+ return `Repeating group ${block.id} has a "${child.kind}" child (${child.id}) \u2014 allowed child kinds are ${GROUP_CHILD_ALLOWED_KINDS.join(", ")}`;
1437
+ }
1438
+ }
1439
+ const duplicateChild = duplicateOptionId(block.fields);
1440
+ if (duplicateChild) {
1441
+ return `Duplicate child id in repeating group ${block.id}: ${duplicateChild}`;
1442
+ }
1443
+ const siblingIds = new Set(block.fields.map((f) => f.id));
1444
+ for (const child of block.fields) {
1445
+ for (const cond of child.visibleIf ?? []) {
1446
+ if (!siblingIds.has(cond.fieldId)) {
1447
+ return `Field ${child.id} in repeating group ${block.id} has a visibleIf referencing "${cond.fieldId}" \u2014 a child's visibleIf can only reference another field in the SAME group`;
1448
+ }
1449
+ }
1450
+ }
1451
+ const estimate = groupSizeEstimate(block);
1452
+ if (estimate > MAX_GROUP_ESTIMATED_BYTES) {
1453
+ return `Repeating group ${block.id} could reach ~${Math.ceil(estimate / 1e3)}KB at ${block.maxInstances} instances \u2014 the maximum is ~${Math.round(MAX_GROUP_ESTIMATED_BYTES / 1e3)}KB; lower maxInstances, shorten text limits, or trim the template`;
1454
+ }
1455
+ }
1456
+ }
1457
+ return null;
1458
+ }
1459
+ function collectGroupChildIds(pages) {
1460
+ const topLevelIds = /* @__PURE__ */ new Set();
1461
+ for (const page of pages) {
1462
+ for (const block of page.blocks) if (isField(block)) topLevelIds.add(block.id);
1463
+ }
1464
+ const childToGroup = /* @__PURE__ */ new Map();
1465
+ for (const page of pages) {
1466
+ for (const block of page.blocks) {
1467
+ if (block.kind !== "repeating_group") continue;
1468
+ for (const child of block.fields) {
1469
+ if (!topLevelIds.has(child.id) && !childToGroup.has(child.id)) {
1470
+ childToGroup.set(child.id, block.id);
1471
+ }
1472
+ }
1473
+ }
1474
+ }
1475
+ return childToGroup;
1476
+ }
1477
+ function groupScopeWallError(pages, rawPages, childToGroup) {
1478
+ for (const page of pages) {
1479
+ for (const block of page.blocks) {
1480
+ for (const cond of block.visibleIf ?? []) {
1481
+ const owner = childToGroup.get(cond.fieldId);
1482
+ if (owner !== void 0) {
1483
+ return `Field ${block.id}'s visibleIf references "${cond.fieldId}", which is a child of repeating group ${owner} \u2014 fields outside a repeating group can't reference its children`;
1484
+ }
1485
+ }
1486
+ }
1487
+ }
1488
+ for (const page of rawPages) {
1489
+ if (!Array.isArray(page.next)) continue;
1490
+ for (const rule of page.next) {
1491
+ if (!rule || typeof rule !== "object") continue;
1492
+ const when2 = rule.when;
1493
+ if (!Array.isArray(when2)) continue;
1494
+ for (const cond of when2) {
1495
+ if (!cond || typeof cond !== "object") continue;
1496
+ const fieldId = cond.fieldId;
1497
+ if (typeof fieldId !== "string") continue;
1498
+ const owner = childToGroup.get(fieldId);
1499
+ if (owner !== void 0) {
1500
+ return `A jump rule on page ${page.id} references "${fieldId}", which is a child of repeating group ${owner} \u2014 jumps can't reference a repeating group's children`;
1501
+ }
1502
+ }
1503
+ }
1504
+ }
1505
+ return null;
1506
+ }
1231
1507
  function baseBlock(rec) {
1232
1508
  return {
1233
1509
  id: str(rec.id, 128),
@@ -1441,6 +1717,33 @@ function normalizeBlock(input) {
1441
1717
  return item ? [item] : [];
1442
1718
  }).slice(0, 50) : void 0
1443
1719
  });
1720
+ case "repeating_group": {
1721
+ const rawFields = Array.isArray(rec.fields) ? rec.fields : [];
1722
+ const fields = rawFields.slice(0, 50).flatMap((raw) => {
1723
+ const child = normalizeBlock(raw);
1724
+ return child && isField(child) ? [child] : [];
1725
+ });
1726
+ return cleanObject({
1727
+ ...baseField(rec, kind),
1728
+ // A container's OWN completeness is governed by minInstances (0 is a
1729
+ // legitimate "this group is optional"), not the generic required
1730
+ // flag — a separately-true `required` would be redundant at best and
1731
+ // self-contradictory at worst (the hidden/calculated precedent).
1732
+ required: false,
1733
+ fields,
1734
+ // Bounds become authoritative in validateRepeatingGroups as HARD
1735
+ // ERRORS, not silent clamps (unlike rating.max/linear_scale.min-max)
1736
+ // — instances are respondent-controlled wire shape, not cosmetic
1737
+ // display config. maxInstances is schema-REQUIRED, so a missing/
1738
+ // non-finite raw value becomes NaN here (fails every bounds check on
1739
+ // its own) rather than a fabricated fallback the post-pass would
1740
+ // have to override anyway.
1741
+ maxInstances: finite(rec.maxInstances) ?? Number.NaN,
1742
+ minInstances: finite(rec.minInstances),
1743
+ addLabel: optionalStr(rec.addLabel, 100),
1744
+ itemLabel: optionalStr(rec.itemLabel, 100)
1745
+ });
1746
+ }
1444
1747
  case "custom":
1445
1748
  return cleanObject({
1446
1749
  ...baseField(rec, kind),
@@ -1529,7 +1832,12 @@ function normalizeFormSchema(input) {
1529
1832
  }
1530
1833
  }
1531
1834
  }
1532
- const calcError = validateCalculatedFields(pages);
1835
+ const groupError = validateRepeatingGroups(pages);
1836
+ if (groupError) return { ok: false, error: groupError };
1837
+ const childToGroup = collectGroupChildIds(pages);
1838
+ const wallError = groupScopeWallError(pages, rebuilt, childToGroup);
1839
+ if (wallError) return { ok: false, error: wallError };
1840
+ const calcError = validateCalculatedFields(pages, childToGroup);
1533
1841
  if (calcError) return { ok: false, error: calcError };
1534
1842
  const calcComplexity = calcComplexityError(pages);
1535
1843
  if (calcComplexity) return { ok: false, error: calcComplexity };
@@ -1618,6 +1926,19 @@ function formatAnswer(field2, value) {
1618
1926
  const text = field2.decimals === void 0 ? String(n) : n.toFixed(Math.max(0, Math.min(6, field2.decimals)));
1619
1927
  return `${field2.prefix ?? ""}${text}${field2.suffix ?? ""}`;
1620
1928
  }
1929
+ case "repeating_group": {
1930
+ if (!Array.isArray(value)) return String(value);
1931
+ const instances = value;
1932
+ if (instances.length === 0) return "";
1933
+ const noun = field2.itemLabel ?? field2.label;
1934
+ const summary = `${instances.length} \xD7 ${noun}`;
1935
+ const firstChild = field2.fields[0];
1936
+ if (!firstChild) return summary;
1937
+ const previews = instances.map(
1938
+ (instance) => instance && typeof instance === "object" && !Array.isArray(instance) ? formatAnswer(firstChild, instance[firstChild.id]) : ""
1939
+ ).filter((text) => text !== "");
1940
+ return previews.length > 0 ? `${summary}: ${previews.join(", ")}` : summary;
1941
+ }
1621
1942
  case "phone": {
1622
1943
  const { country, national, e164 } = parsePhone(String(value));
1623
1944
  if (!e164) return String(value);
@@ -1780,6 +2101,7 @@ var BLOCK_KIND_META = {
1780
2101
  file_upload: { label: "File upload", hint: "Direct to connected storage" },
1781
2102
  hidden: { label: "Hidden field", hint: "Filled from the URL" },
1782
2103
  calculated: { label: "Calculated value", hint: "Computed from other answers" },
2104
+ repeating_group: { label: "Repeating group", hint: "A set of fields respondents can repeat" },
1783
2105
  custom: { label: "Custom", hint: "Your own component (code only)" },
1784
2106
  heading: { label: "Heading", hint: "Section title" },
1785
2107
  paragraph: { label: "Paragraph", hint: "Explanatory text" },
@@ -1851,6 +2173,14 @@ function createBlock(kind) {
1851
2173
  return { id, kind, label: "Hidden field" };
1852
2174
  case "calculated":
1853
2175
  return { id, kind, label: "Calculated value", calc: { op: "const", value: 0 } };
2176
+ case "repeating_group":
2177
+ return {
2178
+ id,
2179
+ kind,
2180
+ label: "Repeating group",
2181
+ maxInstances: 5,
2182
+ fields: [{ id: createId(6), kind: "short_text", label: "Short text" }]
2183
+ };
1854
2184
  case "custom":
1855
2185
  return { id, kind, label: "Custom field", component: "" };
1856
2186
  case "file_upload":
@@ -2884,6 +3214,8 @@ function prefillFromParams(form, params) {
2884
3214
  case "calculated":
2885
3215
  break;
2886
3216
  // engine-computed, never prefillable — a crafted URL can't seed it
3217
+ case "repeating_group":
3218
+ break;
2887
3219
  default:
2888
3220
  data[field2.id] = raw;
2889
3221
  }
@@ -3312,15 +3644,24 @@ function createFormController(options2) {
3312
3644
  data = computeCalculated(form, { ...data, [fieldId]: value });
3313
3645
  onChange?.(data);
3314
3646
  submitError = void 0;
3315
- if (fieldId in errors) {
3316
- errors = Object.fromEntries(Object.entries(errors).filter(([k]) => k !== fieldId));
3647
+ const prefix = `${fieldId}.`;
3648
+ if (fieldId in errors || Object.keys(errors).some((k) => k.startsWith(prefix))) {
3649
+ errors = Object.fromEntries(
3650
+ Object.entries(errors).filter(([k]) => k !== fieldId && !k.startsWith(prefix))
3651
+ );
3317
3652
  }
3318
3653
  notify();
3319
3654
  scheduleDraftSave();
3320
3655
  }
3321
3656
  function setUploading(fieldId, busy) {
3322
- if (busy) uploadingFields.add(fieldId);
3323
- else uploadingFields.delete(fieldId);
3657
+ if (busy) {
3658
+ uploadingFields.add(fieldId);
3659
+ if (fieldId in errors) {
3660
+ errors = Object.fromEntries(Object.entries(errors).filter(([k]) => k !== fieldId));
3661
+ }
3662
+ } else {
3663
+ uploadingFields.delete(fieldId);
3664
+ }
3324
3665
  notify();
3325
3666
  }
3326
3667
  function currentSeqPosition(seq) {
@@ -3820,12 +4161,16 @@ function syncCodeForm(client, form, opts) {
3820
4161
  const content2 = JSON.stringify(form.schema) + JSON.stringify(form.theme ?? null);
3821
4162
  const hash = contentHash(content2);
3822
4163
  const key = `${client.baseUrl}:${client.key}:${form.id}:${hash}`;
4164
+ const revalidateUploadState = allFields(form.schema).some(
4165
+ (field2) => field2.kind === "file_upload"
4166
+ );
3823
4167
  const cached = syncCache.get(key);
3824
4168
  if (cached && !opts?.bypassCache) {
3825
- if (cached.expiresAt === null || cached.expiresAt > Date.now()) return cached.promise;
4169
+ if (cached.expiresAt === null || !revalidateUploadState && cached.expiresAt > Date.now())
4170
+ return cached.promise;
3826
4171
  syncCache.delete(key);
3827
4172
  }
3828
- if (!opts?.bypassCache) {
4173
+ if (!opts?.bypassCache && !revalidateUploadState) {
3829
4174
  const stored = readStoredSync(client, form.id, hash);
3830
4175
  if (stored) {
3831
4176
  const promise2 = Promise.resolve({
@@ -3847,7 +4192,7 @@ function syncCodeForm(client, form, opts) {
3847
4192
  promise.then(
3848
4193
  (result) => {
3849
4194
  if (syncCache.get(key) !== entry) return;
3850
- if (isVolatileSync(result)) {
4195
+ if (revalidateUploadState || isVolatileSync(result)) {
3851
4196
  syncCache.delete(key);
3852
4197
  return;
3853
4198
  }
@@ -3907,6 +4252,9 @@ var JSX_BLOCK_SPECS = [
3907
4252
  field("FileUpload", "file_upload", ["maxFiles", "maxFileSizeMb", "accept"]),
3908
4253
  field("Hidden", "hidden", ["paramName", "defaultValue"]),
3909
4254
  field("Calculated", "calculated", ["calc", "decimals", "prefix", "suffix"]),
4255
+ field("RepeatingGroup", "repeating_group", ["minInstances", "maxInstances", "addLabel", "itemLabel"], {
4256
+ blockChildren: true
4257
+ }),
3910
4258
  field("Custom", "custom", ["component", "config"]),
3911
4259
  content("Heading", "heading", true),
3912
4260
  content("Paragraph", "paragraph", true),
@@ -4010,6 +4358,29 @@ function buildProps(spec, el) {
4010
4358
  );
4011
4359
  }
4012
4360
  out.__childText = parts.join("");
4361
+ } else if (spec.blockChildren && props.children !== void 0) {
4362
+ const childBlocks = flatten(props.children).map((child) => {
4363
+ const childSpec = classify(child);
4364
+ if (childSpec.kind === "__page" || childSpec.kind === "__option" || childSpec.content) {
4365
+ throw new FilloJsxError(
4366
+ "NON_FILLO_CHILD",
4367
+ `Fillo.${spec.name} "${id}" children must be field elements, found Fillo.${childSpec.name} \u2014 a repeating group's template holds answerable fields only`
4368
+ );
4369
+ }
4370
+ return buildProps(childSpec, child);
4371
+ });
4372
+ const childIds = /* @__PURE__ */ new Set();
4373
+ for (const child of childBlocks) {
4374
+ const childId = child.id;
4375
+ if (childIds.has(childId)) {
4376
+ throw new FilloJsxError(
4377
+ "DUPLICATE_ID",
4378
+ `Two fields in repeating group "${id}" share the id "${childId}" \u2014 child ids key instance answers and must be unique within the group`
4379
+ );
4380
+ }
4381
+ childIds.add(childId);
4382
+ }
4383
+ out.__childBlocks = childBlocks;
4013
4384
  } else if (props.children !== void 0) {
4014
4385
  throw new FilloJsxError(
4015
4386
  "NON_FILLO_CHILD",
@@ -4032,6 +4403,16 @@ function buildProps(spec, el) {
4032
4403
  if (spec.textChildren && out.text === void 0) {
4033
4404
  throw new FilloJsxError("TEXT_CHILD_REQUIRED", `Fillo.${spec.name} "${id}" has no text`);
4034
4405
  }
4406
+ if (spec.blockChildren) {
4407
+ if (out.__childBlocks === void 0) {
4408
+ throw new FilloJsxError(
4409
+ "TEXT_CHILD_REQUIRED",
4410
+ `Fillo.${spec.name} "${id}" has no fields \u2014 a repeating group needs at least one field element as a child (empty templates are invalid)`
4411
+ );
4412
+ }
4413
+ out.fields = out.__childBlocks;
4414
+ delete out.__childBlocks;
4415
+ }
4035
4416
  if (props.visibleIf !== void 0) {
4036
4417
  const conditions2 = Array.isArray(props.visibleIf) ? props.visibleIf : [props.visibleIf];
4037
4418
  out.visibleIf = conditions2;
@@ -4139,6 +4520,11 @@ function schemaFromJsx(children, meta) {
4139
4520
  assertUniqueOptionIds(block.id, block.rows, "matrix rows");
4140
4521
  assertUniqueOptionIds(block.id, block.columns, "matrix columns");
4141
4522
  }
4523
+ if (block.kind === "repeating_group") {
4524
+ for (const child of block.fields) {
4525
+ if ("options" in child) assertUniqueOptionIds(child.id, child.options, "options");
4526
+ }
4527
+ }
4142
4528
  }
4143
4529
  }
4144
4530
  return {
@@ -4236,6 +4622,7 @@ export {
4236
4622
  FILLO_CALC_MIN_SDK_VERSION,
4237
4623
  FILLO_CHALLENGE_MIN_SDK_VERSION,
4238
4624
  FILLO_DATA_ATTRS,
4625
+ FILLO_GROUP_MIN_SDK_VERSION,
4239
4626
  FILLO_MIN_SDK_VERSION,
4240
4627
  FILLO_SCHEMA_VERSION,
4241
4628
  FILLO_SDK_VERSION,
@@ -4299,6 +4686,7 @@ export {
4299
4686
  reachableFields,
4300
4687
  reachablePageIds,
4301
4688
  reachablePageSequence,
4689
+ requiredFieldMessage,
4302
4690
  resolveNextPage,
4303
4691
  resolveSlotClass,
4304
4692
  resolveStrings,
@@ -4316,6 +4704,7 @@ export {
4316
4704
  validateResponse,
4317
4705
  visibleBlocks,
4318
4706
  visibleFields,
4707
+ visibleGroupChildren,
4319
4708
  visiblePageBlocks,
4320
4709
  when
4321
4710
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/core",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Form schema, validation, logic engine and JS client for Fillo. Framework-agnostic.",
5
5
  "license": "MIT",
6
6
  "keywords": [