@usefillo/core 0.12.1 → 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`. */
@@ -1814,6 +1904,16 @@ interface FilloFieldStrings {
1814
1904
  errorSummaryTitle: string;
1815
1905
  /** Live-region announcement after a ranking move: "«label», position n of m". */
1816
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;
1817
1917
  /** Live-region announcement once a phone country-picker selection commits
1818
1918
  * (focus moves straight to the national input, so nothing else announces it). */
1819
1919
  phoneCountrySelected: (name: string) => string;
@@ -1954,6 +2054,11 @@ interface BlockSpec {
1954
2054
  optionChildren?: boolean;
1955
2055
  /** String children become the `text` prop (heading/paragraph). */
1956
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;
1957
2062
  /** Content blocks carry no label/required/etc. */
1958
2063
  content?: boolean;
1959
2064
  }
@@ -2074,4 +2179,4 @@ declare class Sha1 {
2074
2179
  }
2075
2180
  declare const sha1Base64: (bytes: Uint8Array) => string;
2076
2181
 
2077
- 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, requiredFieldMessage, 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
  }
@@ -754,6 +793,11 @@ var DEFAULT_FIELD_STRINGS = {
754
793
  submittingAnnouncement: "Submitting your response\u2026",
755
794
  errorSummaryTitle: "Check these fields",
756
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`,
757
801
  phoneCountrySelected: (name) => `${name} selected`,
758
802
  phoneResultsCount: (count) => `${count} ${count === 1 ? "result" : "results"}`,
759
803
  signatureEmpty: "No signature yet",
@@ -778,9 +822,17 @@ var fileValueSchema = z.object({
778
822
  mime: z.string(),
779
823
  url: z.optional(z.string())
780
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
+ }
781
832
  function validateField(field2, value) {
782
833
  if (isEmpty(value)) {
783
834
  if (field2.kind === "calculated") return null;
835
+ if (field2.kind === "repeating_group") return groupCountError(field2, 0);
784
836
  return field2.required ? REQUIRED_FIELD_MESSAGE : null;
785
837
  }
786
838
  switch (field2.kind) {
@@ -900,6 +952,17 @@ function validateField(field2, value) {
900
952
  case "calculated": {
901
953
  return null;
902
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
+ }
903
966
  case "custom": {
904
967
  return null;
905
968
  }
@@ -929,6 +992,10 @@ function validateResponse(form, data) {
929
992
  for (const field2 of fields) {
930
993
  if (field2.kind === "calculated") continue;
931
994
  const value = normalizeValue(field2, data[field2.id]);
995
+ if (field2.kind === "repeating_group") {
996
+ validateGroupResponse(field2, value, errors, cleaned);
997
+ continue;
998
+ }
932
999
  const error = validateField(field2, value);
933
1000
  if (error) {
934
1001
  errors[field2.id] = error;
@@ -938,6 +1005,29 @@ function validateResponse(form, data) {
938
1005
  }
939
1006
  return { ok: Object.keys(errors).length === 0, errors, data: cleaned };
940
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
+ }
941
1031
 
942
1032
  // src/schema-validation.ts
943
1033
  import * as z2 from "zod/mini";
@@ -961,6 +1051,7 @@ var FIELD_KINDS = [
961
1051
  "file_upload",
962
1052
  "hidden",
963
1053
  "calculated",
1054
+ "repeating_group",
964
1055
  "custom"
965
1056
  ];
966
1057
  var CONTENT_KINDS2 = ["heading", "paragraph", "divider"];
@@ -997,10 +1088,11 @@ var schemaShape = z2.object({
997
1088
  });
998
1089
  var MAX_SCHEMA_VERSION = 1;
999
1090
  var FILLO_SCHEMA_VERSION = 1;
1000
- var FILLO_SDK_VERSION = true ? "0.12.1" : "0.0.0-dev";
1091
+ var FILLO_SDK_VERSION = true ? "0.13.0" : "0.0.0-dev";
1001
1092
  var FILLO_MIN_SDK_VERSION = "0.4.0";
1002
1093
  var FILLO_CHALLENGE_MIN_SDK_VERSION = "0.9.0";
1003
1094
  var FILLO_CALC_MIN_SDK_VERSION = "0.11.0";
1095
+ var FILLO_GROUP_MIN_SDK_VERSION = "0.13.0";
1004
1096
  function str(value, max, fallback = "") {
1005
1097
  return typeof value === "string" ? value.trim().slice(0, max) : fallback;
1006
1098
  }
@@ -1170,7 +1262,7 @@ function collectCalcRefs(expr, valueRefs, whenRefs) {
1170
1262
  return;
1171
1263
  }
1172
1264
  }
1173
- function validateCalculatedFields(pages) {
1265
+ function validateCalculatedFields(pages, childToGroup) {
1174
1266
  const kinds = /* @__PURE__ */ new Map();
1175
1267
  for (const page of pages) {
1176
1268
  for (const block of page.blocks) if (isField(block)) kinds.set(block.id, block.kind);
@@ -1190,6 +1282,10 @@ function validateCalculatedFields(pages) {
1190
1282
  for (const ref of valueRefs) {
1191
1283
  const kind = kinds.get(ref);
1192
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
+ }
1193
1289
  return `Calculated field ${block.id} references a missing field: ${ref} \u2014 remove the reference or restore that field`;
1194
1290
  }
1195
1291
  if (!CALC_OPERAND_KINDS.includes(kind)) {
@@ -1263,6 +1359,151 @@ function calcComplexityError(pages) {
1263
1359
  }
1264
1360
  return null;
1265
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
+ }
1266
1507
  function baseBlock(rec) {
1267
1508
  return {
1268
1509
  id: str(rec.id, 128),
@@ -1476,6 +1717,33 @@ function normalizeBlock(input) {
1476
1717
  return item ? [item] : [];
1477
1718
  }).slice(0, 50) : void 0
1478
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
+ }
1479
1747
  case "custom":
1480
1748
  return cleanObject({
1481
1749
  ...baseField(rec, kind),
@@ -1564,7 +1832,12 @@ function normalizeFormSchema(input) {
1564
1832
  }
1565
1833
  }
1566
1834
  }
1567
- 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);
1568
1841
  if (calcError) return { ok: false, error: calcError };
1569
1842
  const calcComplexity = calcComplexityError(pages);
1570
1843
  if (calcComplexity) return { ok: false, error: calcComplexity };
@@ -1653,6 +1926,19 @@ function formatAnswer(field2, value) {
1653
1926
  const text = field2.decimals === void 0 ? String(n) : n.toFixed(Math.max(0, Math.min(6, field2.decimals)));
1654
1927
  return `${field2.prefix ?? ""}${text}${field2.suffix ?? ""}`;
1655
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
+ }
1656
1942
  case "phone": {
1657
1943
  const { country, national, e164 } = parsePhone(String(value));
1658
1944
  if (!e164) return String(value);
@@ -1815,6 +2101,7 @@ var BLOCK_KIND_META = {
1815
2101
  file_upload: { label: "File upload", hint: "Direct to connected storage" },
1816
2102
  hidden: { label: "Hidden field", hint: "Filled from the URL" },
1817
2103
  calculated: { label: "Calculated value", hint: "Computed from other answers" },
2104
+ repeating_group: { label: "Repeating group", hint: "A set of fields respondents can repeat" },
1818
2105
  custom: { label: "Custom", hint: "Your own component (code only)" },
1819
2106
  heading: { label: "Heading", hint: "Section title" },
1820
2107
  paragraph: { label: "Paragraph", hint: "Explanatory text" },
@@ -1886,6 +2173,14 @@ function createBlock(kind) {
1886
2173
  return { id, kind, label: "Hidden field" };
1887
2174
  case "calculated":
1888
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
+ };
1889
2184
  case "custom":
1890
2185
  return { id, kind, label: "Custom field", component: "" };
1891
2186
  case "file_upload":
@@ -2919,6 +3214,8 @@ function prefillFromParams(form, params) {
2919
3214
  case "calculated":
2920
3215
  break;
2921
3216
  // engine-computed, never prefillable — a crafted URL can't seed it
3217
+ case "repeating_group":
3218
+ break;
2922
3219
  default:
2923
3220
  data[field2.id] = raw;
2924
3221
  }
@@ -3347,8 +3644,11 @@ function createFormController(options2) {
3347
3644
  data = computeCalculated(form, { ...data, [fieldId]: value });
3348
3645
  onChange?.(data);
3349
3646
  submitError = void 0;
3350
- if (fieldId in errors) {
3351
- 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
+ );
3352
3652
  }
3353
3653
  notify();
3354
3654
  scheduleDraftSave();
@@ -3952,6 +4252,9 @@ var JSX_BLOCK_SPECS = [
3952
4252
  field("FileUpload", "file_upload", ["maxFiles", "maxFileSizeMb", "accept"]),
3953
4253
  field("Hidden", "hidden", ["paramName", "defaultValue"]),
3954
4254
  field("Calculated", "calculated", ["calc", "decimals", "prefix", "suffix"]),
4255
+ field("RepeatingGroup", "repeating_group", ["minInstances", "maxInstances", "addLabel", "itemLabel"], {
4256
+ blockChildren: true
4257
+ }),
3955
4258
  field("Custom", "custom", ["component", "config"]),
3956
4259
  content("Heading", "heading", true),
3957
4260
  content("Paragraph", "paragraph", true),
@@ -4055,6 +4358,29 @@ function buildProps(spec, el) {
4055
4358
  );
4056
4359
  }
4057
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;
4058
4384
  } else if (props.children !== void 0) {
4059
4385
  throw new FilloJsxError(
4060
4386
  "NON_FILLO_CHILD",
@@ -4077,6 +4403,16 @@ function buildProps(spec, el) {
4077
4403
  if (spec.textChildren && out.text === void 0) {
4078
4404
  throw new FilloJsxError("TEXT_CHILD_REQUIRED", `Fillo.${spec.name} "${id}" has no text`);
4079
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
+ }
4080
4416
  if (props.visibleIf !== void 0) {
4081
4417
  const conditions2 = Array.isArray(props.visibleIf) ? props.visibleIf : [props.visibleIf];
4082
4418
  out.visibleIf = conditions2;
@@ -4184,6 +4520,11 @@ function schemaFromJsx(children, meta) {
4184
4520
  assertUniqueOptionIds(block.id, block.rows, "matrix rows");
4185
4521
  assertUniqueOptionIds(block.id, block.columns, "matrix columns");
4186
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
+ }
4187
4528
  }
4188
4529
  }
4189
4530
  return {
@@ -4281,6 +4622,7 @@ export {
4281
4622
  FILLO_CALC_MIN_SDK_VERSION,
4282
4623
  FILLO_CHALLENGE_MIN_SDK_VERSION,
4283
4624
  FILLO_DATA_ATTRS,
4625
+ FILLO_GROUP_MIN_SDK_VERSION,
4284
4626
  FILLO_MIN_SDK_VERSION,
4285
4627
  FILLO_SCHEMA_VERSION,
4286
4628
  FILLO_SDK_VERSION,
@@ -4362,6 +4704,7 @@ export {
4362
4704
  validateResponse,
4363
4705
  visibleBlocks,
4364
4706
  visibleFields,
4707
+ visibleGroupChildren,
4365
4708
  visiblePageBlocks,
4366
4709
  when
4367
4710
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/core",
3
- "version": "0.12.1",
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": [