@usefillo/core 0.11.0 → 0.12.1

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
@@ -66,9 +66,12 @@ interface NumberField extends BaseField {
66
66
  decimals?: number;
67
67
  prefix?: string;
68
68
  suffix?: string;
69
- /** Locale-aware thousand separators in the SDK input. Display-only;
70
- * never reaches the wire, the server, or exports. */
71
- notation?: "grouped";
69
+ /** Thousand separators in the SDK input. Display-only; never reaches the
70
+ * wire, the server, or exports. Named by the GROUP separator:
71
+ * - `"grouped"`: detect from the respondent's browser locale;
72
+ * - `"grouped-comma"`: fixed comma groups, dot decimal — `1,234.56`;
73
+ * - `"grouped-dot"`: fixed dot groups, comma decimal — `1.234,56`. */
74
+ notation?: "grouped" | "grouped-comma" | "grouped-dot";
72
75
  }
73
76
  interface ChoiceField extends BaseField {
74
77
  kind: "select" | "multi_select" | "dropdown";
@@ -660,14 +663,26 @@ declare function normalizeFormTheme(input: unknown): FormTheme | null;
660
663
  declare function formatAnswer(field: Field, value: FieldValue): string;
661
664
 
662
665
  /**
663
- * Grouped-number display + parsing for the Number field's `notation:
664
- * "grouped"` input (design contract: docs/decisions/number-formatting.md).
665
- * Both helpers are pure, side-effect-free, and DOM-free, so the same code
666
- * runs in @usefillo/react, @usefillo/dom, and Node. Both are strictly
667
- * display-only — grouping never touches validation, canonical storage, or
668
- * the wire (contract decision 2) — and both degrade to canonical text
669
- * instead of throwing when `Intl` is unavailable.
666
+ * Grouped-number display + parsing for the Number field's `notation` input
667
+ * — browser-locale detection (`"grouped"`) or an author-fixed style
668
+ * (`"grouped-comma"` = `1,234.56`, `"grouped-dot"` = `1.234,56`); design
669
+ * contract: docs/decisions/number-formatting.md. All helpers are pure,
670
+ * side-effect-free, and DOM-free, so the same code runs in @usefillo/react,
671
+ * @usefillo/dom, and Node. All are strictly display-only — grouping never
672
+ * touches validation, canonical storage, or the wire (contract decision 2)
673
+ * — and all degrade to canonical text instead of throwing when `Intl` is
674
+ * unavailable.
670
675
  */
676
+ /**
677
+ * The `Intl` locale a `notation` value pins the separators to — the single
678
+ * source of the notation→locale map, consumed by BOTH renderers (never
679
+ * duplicate it). Notation values are named by the GROUP separator:
680
+ * - `"grouped"` (and unset): `undefined` — detect from the respondent's
681
+ * browser locale, exactly the pre-fixed-style behavior;
682
+ * - `"grouped-comma"`: `"en-US"` — comma groups, dot decimal, `1,234.56`;
683
+ * - `"grouped-dot"`: `"de-DE"` — dot groups, comma decimal, `1.234,56`.
684
+ */
685
+ declare function localeForNotation(notation?: "grouped" | "grouped-comma" | "grouped-dot"): string | undefined;
671
686
  /**
672
687
  * Locale-grouped display text for a finite number — the blurred value of a
673
688
  * `notation: "grouped"` number input. `maximumFractionDigits` is set
@@ -697,8 +712,10 @@ declare function formatGroupedNumber(value: number, opts?: {
697
712
  * accepted as a group separator whenever the locale's own is whitespace;
698
713
  * - maps the locale decimal mark to ".";
699
714
  * - ALWAYS also accepts "." as a decimal mark regardless of locale — see
700
- * {@link resolveDotGroup} for the one locale family where that collides
701
- * with "." also being the group separator;
715
+ * {@link resolveAmbiguousGrouping} for the locale families where a "." or
716
+ * "," group separator collides with a decimal reading (a "," group run
717
+ * that isn't exact 3-digit chunking reads as a decimal, so "12,5" under a
718
+ * comma-grouping locale is 12.5, never a silently-stripped 125);
702
719
  * - preserves a leading minus;
703
720
  * - preserves partial typing states ("1234." stays "1234." — `Number()` can
704
721
  * still parse it, and rewriting mid-keystroke fights the respondent).
@@ -1362,7 +1379,9 @@ interface FormControllerOptions {
1362
1379
  getHoneypot?: () => string;
1363
1380
  /**
1364
1381
  * @internal Let preview page navigation move forward without validating the
1365
- * current page. Submission still validates, and renderers must not use this
1382
+ * current page. Submission still validates every answerable field; a
1383
+ * transportless preview ignores required file-upload fields because its
1384
+ * disabled picker cannot possibly satisfy them. Renderers must not use this
1366
1385
  * option to change visitor-facing behavior such as branding.
1367
1386
  */
1368
1387
  skipValidation?: boolean;
@@ -1677,8 +1696,8 @@ declare function resolveThemeAppearance(theme: FormTheme | null): FormTheme | nu
1677
1696
  /**
1678
1697
  * The default English validation message for a required field left empty.
1679
1698
  * `validateField` returns this exact sentinel (core has no strings context),
1680
- * and the React render layer swaps it for `strings.required` so a localized
1681
- * site can translate it. Keep it identical to `DEFAULT_FIELD_STRINGS.required`.
1699
+ * and renderer layers swap it for field-aware/localized copy. Keep it
1700
+ * identical to `DEFAULT_FIELD_STRINGS.required`.
1682
1701
  */
1683
1702
  declare const REQUIRED_FIELD_MESSAGE = "This field is required";
1684
1703
  /**
@@ -1744,8 +1763,12 @@ declare const DEFAULT_STRINGS: FilloStrings;
1744
1763
  * an exhaustively-enumerated, closed set.
1745
1764
  */
1746
1765
  interface FilloFieldStrings {
1747
- /** Validation: a required field was left empty. Mirrors REQUIRED_FIELD_MESSAGE. */
1766
+ /** Generic/legacy validation copy for a required field left empty. Mirrors
1767
+ * REQUIRED_FIELD_MESSAGE and remains the fallback for custom/hidden fields. */
1748
1768
  required: string;
1769
+ /** Optional field-aware required copy. Existing `required` overrides keep
1770
+ * their legacy generic behavior unless this function is also supplied. */
1771
+ requiredForField?: (field: Field) => string;
1749
1772
  /** Notice when a spent/expired resume link couldn't restore progress. */
1750
1773
  resumeLinkExpired: string;
1751
1774
  /** Success-screen message when a verified identity re-submits and the form
@@ -1753,8 +1776,18 @@ interface FilloFieldStrings {
1753
1776
  alreadyAnswered: string;
1754
1777
  /** Retry control on a failed upload row. */
1755
1778
  uploadRetry: string;
1756
- /** Dropzone copy when uploads can't run (no client / preview). */
1779
+ /** Accessible action names for upload rows. The filename is appended by the renderer. */
1780
+ uploadCancel: string;
1781
+ uploadRemove: string;
1782
+ uploadDismiss: string;
1783
+ /** Visible secondary status for an upload in progress. */
1784
+ uploadingFile: (percent: number, size: string) => string;
1785
+ /** Visible secondary status for a completed upload. */
1786
+ uploadedFile: (size: string) => string;
1787
+ /** Dropzone copy when uploads can't run because the form isn't connected. */
1757
1788
  uploadsDisabled: string;
1789
+ /** Dropzone copy for an explicit render-only preview, which never has transport. */
1790
+ uploadsRenderOnly: string;
1758
1791
  /** Dropzone copy when the server temporarily refuses new file sessions. */
1759
1792
  uploadsUnavailable: string;
1760
1793
  /** An upload request reached the server, but storage could not accept it. */
@@ -1776,8 +1809,8 @@ interface FilloFieldStrings {
1776
1809
  /** Live-region announcement while a submit is in flight — for auto-submit
1777
1810
  * forms, which have no footer/button to show `submitting` on. */
1778
1811
  submittingAnnouncement: string;
1779
- /** Heading on the failed-submit error summary (one `role="alert"` summary
1780
- * linking to fields, GOV.UK pattern — replaces N competing per-field alerts). */
1812
+ /** @deprecated Retained for localization compatibility. Default renderers
1813
+ * now focus the first invalid control and show field-aware inline guidance. */
1781
1814
  errorSummaryTitle: string;
1782
1815
  /** Live-region announcement after a ranking move: "«label», position n of m". */
1783
1816
  rankingPosition: (label: string, position: number, count: number) => string;
@@ -1795,8 +1828,12 @@ interface FilloFieldStrings {
1795
1828
  declare const DEFAULT_FIELD_STRINGS: FilloFieldStrings;
1796
1829
  /** Everything the default renderers can localize — chrome + field/validation. */
1797
1830
  type FilloRendererStrings = FilloStrings & FilloFieldStrings;
1831
+ /** Resolve the renderer copy for core's stable required-field sentinel. */
1832
+ declare function requiredFieldMessage(field: Field, strings: Pick<FilloFieldStrings, "required" | "requiredForField">): string;
1798
1833
  /** Merge overrides over the built-in chrome + field defaults. Accepts a partial
1799
- * of the full renderer surface so the `strings` prop can override either set. */
1834
+ * of the full renderer surface so the `strings` prop can override either set.
1835
+ * A legacy generic `required` override disables the new field-aware default
1836
+ * unless the caller explicitly supplies `requiredForField` too. */
1800
1837
  declare function resolveStrings(overrides?: Partial<FilloRendererStrings>): FilloRendererStrings;
1801
1838
 
1802
1839
  /** Result of syncing a code-defined form: its canonical id, slug, and branding. */
@@ -2037,4 +2074,4 @@ declare class Sha1 {
2037
2074
  }
2038
2075
  declare const sha1Base64: (bytes: Uint8Array) => string;
2039
2076
 
2040
- 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, 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 };
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 };
package/dist/index.js CHANGED
@@ -666,10 +666,12 @@ function positionPhonePopover(anchor, popover) {
666
666
  popover.style.setProperty("--fillo-phone-popover-max-width", `${Math.floor(maxWidth)}px`);
667
667
  popover.style.setProperty("--fillo-phone-popover-offset-x", "0px");
668
668
  const width = Math.min(popover.offsetWidth, maxWidth);
669
+ const rtl = getComputedStyle(anchor).direction === "rtl";
670
+ const unshiftedLeft = rtl ? anchorRect.right - width : anchorRect.left;
669
671
  let offsetX = 0;
670
- const rightOverflow = anchorRect.left + width - (viewportWidth - gap);
672
+ const rightOverflow = unshiftedLeft + width - (viewportWidth - gap);
671
673
  if (rightOverflow > 0) offsetX -= rightOverflow;
672
- const leftOverflow = gap - (anchorRect.left + offsetX);
674
+ const leftOverflow = gap - (unshiftedLeft + offsetX);
673
675
  if (leftOverflow > 0) offsetX += leftOverflow;
674
676
  popover.style.setProperty("--fillo-phone-popover-offset-x", `${Math.round(offsetX)}px`);
675
677
  return placement;
@@ -705,12 +707,41 @@ var DEFAULT_STRINGS = {
705
707
  resumeStartOver: "Start over",
706
708
  challengeUnavailable: "The verification check couldn't load. Refresh the page, or check that challenges.cloudflare.com isn't blocked."
707
709
  };
710
+ var REQUIRED_BY_KIND = {
711
+ short_text: "Enter your answer",
712
+ long_text: "Enter your answer",
713
+ email: "Enter an email address",
714
+ url: "Enter a URL",
715
+ phone: "Enter a phone number",
716
+ number: "Enter a number",
717
+ select: "Choose an option",
718
+ dropdown: "Choose an option",
719
+ multi_select: "Select at least one option",
720
+ checkbox: "Select this checkbox",
721
+ rating: "Choose a rating",
722
+ linear_scale: "Choose a value",
723
+ ranking: "Rank every option",
724
+ matrix: "Answer every row",
725
+ signature: "Add your signature",
726
+ date: "Enter a date",
727
+ file_upload: "Add a file"
728
+ };
729
+ function defaultRequiredForField(field2) {
730
+ return REQUIRED_BY_KIND[field2.kind] ?? REQUIRED_FIELD_MESSAGE;
731
+ }
708
732
  var DEFAULT_FIELD_STRINGS = {
709
733
  required: REQUIRED_FIELD_MESSAGE,
734
+ requiredForField: defaultRequiredForField,
710
735
  resumeLinkExpired: "That saved-progress link expired or was already used \u2014 start again below.",
711
736
  alreadyAnswered: "You've already answered this form.",
712
737
  uploadRetry: "Retry",
713
- uploadsDisabled: "Uploads are disabled in preview",
738
+ uploadCancel: "Cancel",
739
+ uploadRemove: "Remove",
740
+ uploadDismiss: "Dismiss",
741
+ uploadingFile: (percent, size) => `Uploading \xB7 ${percent}% \xB7 ${size}`,
742
+ uploadedFile: (size) => `Uploaded \xB7 ${size}`,
743
+ uploadsDisabled: "Connect this form to Fillo to enable uploads",
744
+ uploadsRenderOnly: "Uploads are unavailable in this render-only preview",
714
745
  uploadsUnavailable: "Uploads are temporarily unavailable",
715
746
  uploadUnavailable: "We couldn't upload this file right now. Try again in a moment.",
716
747
  uploadFailed: "Upload failed \u2014 try again",
@@ -721,15 +752,22 @@ var DEFAULT_FIELD_STRINGS = {
721
752
  uploadsFailed: (count) => `${count} ${count === 1 ? "upload" : "uploads"} failed.`,
722
753
  filesUploaded: (count) => `${count} ${count === 1 ? "file" : "files"} uploaded.`,
723
754
  submittingAnnouncement: "Submitting your response\u2026",
724
- errorSummaryTitle: "Please fix the following:",
755
+ errorSummaryTitle: "Check these fields",
725
756
  rankingPosition: (label, position, count) => `${label}, position ${position} of ${count}`,
726
757
  phoneCountrySelected: (name) => `${name} selected`,
727
758
  phoneResultsCount: (count) => `${count} ${count === 1 ? "result" : "results"}`,
728
759
  signatureEmpty: "No signature yet",
729
760
  signatureSigned: "Signature saved"
730
761
  };
762
+ function requiredFieldMessage(field2, strings) {
763
+ return strings.requiredForField?.(field2) ?? strings.required;
764
+ }
731
765
  function resolveStrings(overrides) {
732
- return overrides ? { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS, ...overrides } : { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS };
766
+ const resolved = overrides ? { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS, ...overrides } : { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS };
767
+ if (overrides && Object.prototype.hasOwnProperty.call(overrides, "required") && !Object.prototype.hasOwnProperty.call(overrides, "requiredForField")) {
768
+ delete resolved.requiredForField;
769
+ }
770
+ return resolved;
733
771
  }
734
772
 
735
773
  // src/validation.ts
@@ -959,7 +997,7 @@ var schemaShape = z2.object({
959
997
  });
960
998
  var MAX_SCHEMA_VERSION = 1;
961
999
  var FILLO_SCHEMA_VERSION = 1;
962
- var FILLO_SDK_VERSION = true ? "0.11.0" : "0.0.0-dev";
1000
+ var FILLO_SDK_VERSION = true ? "0.12.1" : "0.0.0-dev";
963
1001
  var FILLO_MIN_SDK_VERSION = "0.4.0";
964
1002
  var FILLO_CHALLENGE_MIN_SDK_VERSION = "0.9.0";
965
1003
  var FILLO_CALC_MIN_SDK_VERSION = "0.11.0";
@@ -1346,9 +1384,9 @@ function normalizeBlock(input) {
1346
1384
  decimals: finite(rec.decimals) === void 0 ? void 0 : int(rec.decimals, 0, 6, 0),
1347
1385
  prefix: optionalAffix(rec.prefix, 100),
1348
1386
  suffix: optionalAffix(rec.suffix, 100),
1349
- // The only supported value; anything else drops silently rather than
1387
+ // The only supported values; anything else drops silently rather than
1350
1388
  // erroring (an old/garbage value is just "no grouping").
1351
- notation: rec.notation === "grouped" ? "grouped" : void 0
1389
+ notation: rec.notation === "grouped" ? "grouped" : rec.notation === "grouped-comma" ? "grouped-comma" : rec.notation === "grouped-dot" ? "grouped-dot" : void 0
1352
1390
  });
1353
1391
  case "select":
1354
1392
  case "multi_select":
@@ -1640,6 +1678,17 @@ function formatAnswer(field2, value) {
1640
1678
  function clampDecimals2(decimals) {
1641
1679
  return Math.max(0, Math.min(6, Math.round(decimals)));
1642
1680
  }
1681
+ function localeForNotation(notation) {
1682
+ switch (notation) {
1683
+ case "grouped-comma":
1684
+ return "en-US";
1685
+ case "grouped-dot":
1686
+ return "de-DE";
1687
+ case "grouped":
1688
+ case void 0:
1689
+ return void 0;
1690
+ }
1691
+ }
1643
1692
  function formatGroupedNumber(value, opts) {
1644
1693
  if (!Number.isFinite(value)) return String(value);
1645
1694
  const decimals = opts?.decimals === void 0 ? void 0 : clampDecimals2(opts.decimals);
@@ -1661,17 +1710,17 @@ function stripAll(text, separator) {
1661
1710
  function toCanonicalDecimal(text, decimal) {
1662
1711
  return decimal === "." ? text : text.split(decimal).join(".");
1663
1712
  }
1664
- function resolveDotGroup(body, decimal) {
1665
- if (decimal !== "." && body.includes(decimal)) {
1713
+ function resolveAmbiguousGrouping(body, group, decimal) {
1714
+ if (decimal !== group && body.includes(decimal)) {
1666
1715
  const i = body.lastIndexOf(decimal);
1667
- const intPart = stripAll(body.slice(0, i), ".");
1716
+ const intPart = stripAll(body.slice(0, i), group);
1668
1717
  const fracPart = body.slice(i + decimal.length);
1669
1718
  return `${intPart}.${fracPart}`;
1670
1719
  }
1671
- const segments = body.split(".");
1720
+ const segments = body.split(group);
1672
1721
  if (segments.length === 1) return body;
1673
1722
  const isPureGrouping = segments.every(
1674
- (seg, i) => i === 0 ? /^\d{1,3}$/.test(seg) : /^\d{3}$/.test(seg)
1723
+ (seg, i) => i === 0 ? /^[1-9]\d{0,2}$/.test(seg) : /^\d{3}$/.test(seg)
1675
1724
  );
1676
1725
  if (isPureGrouping) return segments.join("");
1677
1726
  return `${segments.slice(0, -1).join("")}.${segments.at(-1) ?? ""}`;
@@ -1687,8 +1736,8 @@ function parseGroupedNumber(text, locale) {
1687
1736
  if (negative) body = body.slice(1);
1688
1737
  if (/^\s$/.test(group)) {
1689
1738
  body = toCanonicalDecimal(body.replace(/\s/g, ""), decimal);
1690
- } else if (group === ".") {
1691
- body = resolveDotGroup(body, decimal);
1739
+ } else if (group === "." || group === ",") {
1740
+ body = resolveAmbiguousGrouping(body, group, decimal);
1692
1741
  } else {
1693
1742
  body = toCanonicalDecimal(stripAll(body, group), decimal);
1694
1743
  }
@@ -3305,8 +3354,14 @@ function createFormController(options2) {
3305
3354
  scheduleDraftSave();
3306
3355
  }
3307
3356
  function setUploading(fieldId, busy) {
3308
- if (busy) uploadingFields.add(fieldId);
3309
- else uploadingFields.delete(fieldId);
3357
+ if (busy) {
3358
+ uploadingFields.add(fieldId);
3359
+ if (fieldId in errors) {
3360
+ errors = Object.fromEntries(Object.entries(errors).filter(([k]) => k !== fieldId));
3361
+ }
3362
+ } else {
3363
+ uploadingFields.delete(fieldId);
3364
+ }
3310
3365
  notify();
3311
3366
  }
3312
3367
  function currentSeqPosition(seq) {
@@ -3366,9 +3421,26 @@ function createFormController(options2) {
3366
3421
  if (status === "submitting" || status === "submitted") return;
3367
3422
  const result = validateResponse(form, data);
3368
3423
  if (!result.ok) {
3369
- errors = result.errors;
3424
+ const transportlessPreview = options2.skipValidation && !(client && formId);
3425
+ const previewErrors = transportlessPreview ? Object.fromEntries(
3426
+ Object.entries(result.errors).filter(
3427
+ ([fieldId]) => form.pages.every(
3428
+ (page) => page.blocks.every(
3429
+ (block) => !(isField(block) && block.id === fieldId && block.kind === "file_upload")
3430
+ )
3431
+ )
3432
+ )
3433
+ ) : result.errors;
3434
+ if (transportlessPreview && Object.keys(previewErrors).length === 0) {
3435
+ errors = {};
3436
+ status = "submitted";
3437
+ notify();
3438
+ emitSubmitted(void 0, data);
3439
+ return;
3440
+ }
3441
+ errors = previewErrors;
3370
3442
  const firstBad = form.pages.findIndex(
3371
- (p) => visiblePageBlocks(form, p, data).some((b) => b.id in result.errors)
3443
+ (p) => visiblePageBlocks(form, p, data).some((b) => b.id in previewErrors)
3372
3444
  );
3373
3445
  if (firstBad >= 0) pageIndex = firstBad;
3374
3446
  notify();
@@ -3789,12 +3861,16 @@ function syncCodeForm(client, form, opts) {
3789
3861
  const content2 = JSON.stringify(form.schema) + JSON.stringify(form.theme ?? null);
3790
3862
  const hash = contentHash(content2);
3791
3863
  const key = `${client.baseUrl}:${client.key}:${form.id}:${hash}`;
3864
+ const revalidateUploadState = allFields(form.schema).some(
3865
+ (field2) => field2.kind === "file_upload"
3866
+ );
3792
3867
  const cached = syncCache.get(key);
3793
3868
  if (cached && !opts?.bypassCache) {
3794
- if (cached.expiresAt === null || cached.expiresAt > Date.now()) return cached.promise;
3869
+ if (cached.expiresAt === null || !revalidateUploadState && cached.expiresAt > Date.now())
3870
+ return cached.promise;
3795
3871
  syncCache.delete(key);
3796
3872
  }
3797
- if (!opts?.bypassCache) {
3873
+ if (!opts?.bypassCache && !revalidateUploadState) {
3798
3874
  const stored = readStoredSync(client, form.id, hash);
3799
3875
  if (stored) {
3800
3876
  const promise2 = Promise.resolve({
@@ -3816,7 +3892,7 @@ function syncCodeForm(client, form, opts) {
3816
3892
  promise.then(
3817
3893
  (result) => {
3818
3894
  if (syncCache.get(key) !== entry) return;
3819
- if (isVolatileSync(result)) {
3895
+ if (revalidateUploadState || isVolatileSync(result)) {
3820
3896
  syncCache.delete(key);
3821
3897
  return;
3822
3898
  }
@@ -4252,6 +4328,7 @@ export {
4252
4328
  isPossiblePhone,
4253
4329
  isTerminalPage,
4254
4330
  isValidPartialNumberText,
4331
+ localeForNotation,
4255
4332
  needsExplicitSubmit,
4256
4333
  normalizeFormSchema,
4257
4334
  normalizeFormTheme,
@@ -4267,6 +4344,7 @@ export {
4267
4344
  reachableFields,
4268
4345
  reachablePageIds,
4269
4346
  reachablePageSequence,
4347
+ requiredFieldMessage,
4270
4348
  resolveNextPage,
4271
4349
  resolveSlotClass,
4272
4350
  resolveStrings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/core",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "Form schema, validation, logic engine and JS client for Fillo. Framework-agnostic.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -32,6 +32,7 @@
32
32
  "zod": "^4.4.3"
33
33
  },
34
34
  "devDependencies": {
35
+ "jsdom": "^25.0.1",
35
36
  "tsup": "^8.4.0",
36
37
  "typescript": "^5.8.3"
37
38
  },