@usefillo/core 0.11.0 → 0.12.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
@@ -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;
@@ -1753,8 +1772,10 @@ interface FilloFieldStrings {
1753
1772
  alreadyAnswered: string;
1754
1773
  /** Retry control on a failed upload row. */
1755
1774
  uploadRetry: string;
1756
- /** Dropzone copy when uploads can't run (no client / preview). */
1775
+ /** Dropzone copy when uploads can't run because the form isn't connected. */
1757
1776
  uploadsDisabled: string;
1777
+ /** Dropzone copy for an explicit render-only preview, which never has transport. */
1778
+ uploadsRenderOnly: string;
1758
1779
  /** Dropzone copy when the server temporarily refuses new file sessions. */
1759
1780
  uploadsUnavailable: string;
1760
1781
  /** An upload request reached the server, but storage could not accept it. */
@@ -2037,4 +2058,4 @@ declare class Sha1 {
2037
2058
  }
2038
2059
  declare const sha1Base64: (bytes: Uint8Array) => string;
2039
2060
 
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 };
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 };
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;
@@ -710,7 +712,8 @@ var DEFAULT_FIELD_STRINGS = {
710
712
  resumeLinkExpired: "That saved-progress link expired or was already used \u2014 start again below.",
711
713
  alreadyAnswered: "You've already answered this form.",
712
714
  uploadRetry: "Retry",
713
- uploadsDisabled: "Uploads are disabled in preview",
715
+ uploadsDisabled: "Connect this form to Fillo to enable uploads",
716
+ uploadsRenderOnly: "Uploads are unavailable in this render-only preview",
714
717
  uploadsUnavailable: "Uploads are temporarily unavailable",
715
718
  uploadUnavailable: "We couldn't upload this file right now. Try again in a moment.",
716
719
  uploadFailed: "Upload failed \u2014 try again",
@@ -959,7 +962,7 @@ var schemaShape = z2.object({
959
962
  });
960
963
  var MAX_SCHEMA_VERSION = 1;
961
964
  var FILLO_SCHEMA_VERSION = 1;
962
- var FILLO_SDK_VERSION = true ? "0.11.0" : "0.0.0-dev";
965
+ var FILLO_SDK_VERSION = true ? "0.12.0" : "0.0.0-dev";
963
966
  var FILLO_MIN_SDK_VERSION = "0.4.0";
964
967
  var FILLO_CHALLENGE_MIN_SDK_VERSION = "0.9.0";
965
968
  var FILLO_CALC_MIN_SDK_VERSION = "0.11.0";
@@ -1346,9 +1349,9 @@ function normalizeBlock(input) {
1346
1349
  decimals: finite(rec.decimals) === void 0 ? void 0 : int(rec.decimals, 0, 6, 0),
1347
1350
  prefix: optionalAffix(rec.prefix, 100),
1348
1351
  suffix: optionalAffix(rec.suffix, 100),
1349
- // The only supported value; anything else drops silently rather than
1352
+ // The only supported values; anything else drops silently rather than
1350
1353
  // erroring (an old/garbage value is just "no grouping").
1351
- notation: rec.notation === "grouped" ? "grouped" : void 0
1354
+ notation: rec.notation === "grouped" ? "grouped" : rec.notation === "grouped-comma" ? "grouped-comma" : rec.notation === "grouped-dot" ? "grouped-dot" : void 0
1352
1355
  });
1353
1356
  case "select":
1354
1357
  case "multi_select":
@@ -1640,6 +1643,17 @@ function formatAnswer(field2, value) {
1640
1643
  function clampDecimals2(decimals) {
1641
1644
  return Math.max(0, Math.min(6, Math.round(decimals)));
1642
1645
  }
1646
+ function localeForNotation(notation) {
1647
+ switch (notation) {
1648
+ case "grouped-comma":
1649
+ return "en-US";
1650
+ case "grouped-dot":
1651
+ return "de-DE";
1652
+ case "grouped":
1653
+ case void 0:
1654
+ return void 0;
1655
+ }
1656
+ }
1643
1657
  function formatGroupedNumber(value, opts) {
1644
1658
  if (!Number.isFinite(value)) return String(value);
1645
1659
  const decimals = opts?.decimals === void 0 ? void 0 : clampDecimals2(opts.decimals);
@@ -1661,17 +1675,17 @@ function stripAll(text, separator) {
1661
1675
  function toCanonicalDecimal(text, decimal) {
1662
1676
  return decimal === "." ? text : text.split(decimal).join(".");
1663
1677
  }
1664
- function resolveDotGroup(body, decimal) {
1665
- if (decimal !== "." && body.includes(decimal)) {
1678
+ function resolveAmbiguousGrouping(body, group, decimal) {
1679
+ if (decimal !== group && body.includes(decimal)) {
1666
1680
  const i = body.lastIndexOf(decimal);
1667
- const intPart = stripAll(body.slice(0, i), ".");
1681
+ const intPart = stripAll(body.slice(0, i), group);
1668
1682
  const fracPart = body.slice(i + decimal.length);
1669
1683
  return `${intPart}.${fracPart}`;
1670
1684
  }
1671
- const segments = body.split(".");
1685
+ const segments = body.split(group);
1672
1686
  if (segments.length === 1) return body;
1673
1687
  const isPureGrouping = segments.every(
1674
- (seg, i) => i === 0 ? /^\d{1,3}$/.test(seg) : /^\d{3}$/.test(seg)
1688
+ (seg, i) => i === 0 ? /^[1-9]\d{0,2}$/.test(seg) : /^\d{3}$/.test(seg)
1675
1689
  );
1676
1690
  if (isPureGrouping) return segments.join("");
1677
1691
  return `${segments.slice(0, -1).join("")}.${segments.at(-1) ?? ""}`;
@@ -1687,8 +1701,8 @@ function parseGroupedNumber(text, locale) {
1687
1701
  if (negative) body = body.slice(1);
1688
1702
  if (/^\s$/.test(group)) {
1689
1703
  body = toCanonicalDecimal(body.replace(/\s/g, ""), decimal);
1690
- } else if (group === ".") {
1691
- body = resolveDotGroup(body, decimal);
1704
+ } else if (group === "." || group === ",") {
1705
+ body = resolveAmbiguousGrouping(body, group, decimal);
1692
1706
  } else {
1693
1707
  body = toCanonicalDecimal(stripAll(body, group), decimal);
1694
1708
  }
@@ -3366,9 +3380,26 @@ function createFormController(options2) {
3366
3380
  if (status === "submitting" || status === "submitted") return;
3367
3381
  const result = validateResponse(form, data);
3368
3382
  if (!result.ok) {
3369
- errors = result.errors;
3383
+ const transportlessPreview = options2.skipValidation && !(client && formId);
3384
+ const previewErrors = transportlessPreview ? Object.fromEntries(
3385
+ Object.entries(result.errors).filter(
3386
+ ([fieldId]) => form.pages.every(
3387
+ (page) => page.blocks.every(
3388
+ (block) => !(isField(block) && block.id === fieldId && block.kind === "file_upload")
3389
+ )
3390
+ )
3391
+ )
3392
+ ) : result.errors;
3393
+ if (transportlessPreview && Object.keys(previewErrors).length === 0) {
3394
+ errors = {};
3395
+ status = "submitted";
3396
+ notify();
3397
+ emitSubmitted(void 0, data);
3398
+ return;
3399
+ }
3400
+ errors = previewErrors;
3370
3401
  const firstBad = form.pages.findIndex(
3371
- (p) => visiblePageBlocks(form, p, data).some((b) => b.id in result.errors)
3402
+ (p) => visiblePageBlocks(form, p, data).some((b) => b.id in previewErrors)
3372
3403
  );
3373
3404
  if (firstBad >= 0) pageIndex = firstBad;
3374
3405
  notify();
@@ -4252,6 +4283,7 @@ export {
4252
4283
  isPossiblePhone,
4253
4284
  isTerminalPage,
4254
4285
  isValidPartialNumberText,
4286
+ localeForNotation,
4255
4287
  needsExplicitSubmit,
4256
4288
  normalizeFormSchema,
4257
4289
  normalizeFormTheme,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/core",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
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
  },