formstand 0.19.0 → 0.20.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/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.0 — 2026-09-18
4
+
5
+ ### Added
6
+
7
+ - **`useMaskedInput(field, { parse, format })`.** The raw-text editing
8
+ pattern behind `useNumberInput`, generalized to any parsed/formatted
9
+ value (phone, currency, locale formats): partial entries stay visible
10
+ as local text without touching the form, keystrokes that parse push
11
+ the REAL value immediately (the schema validates data, never display
12
+ text), `"empty"` writes the field's schema-aware `emptyValue`, blur
13
+ snaps the display to `format(value)`, and an external write wins over
14
+ local text. Recipes gains a phone-mask walkthrough.
15
+ - **`formstand/testing`: drive a form in tests the way the hooks do.**
16
+ `fillField` writes and validates under the CHANGE gate (no-op guard
17
+ included), `blurField` marks touched and validates under the BLUR
18
+ gate, `fillAndBlurField` is the edit-then-leave a user performs,
19
+ `fillFields` applies a fixture object through the full recipe, and
20
+ `submitForm` hands back the `SubmitResult` to switch on. Tests using
21
+ the imperative `form.setValue` were skipping the validation gates by
22
+ design; these helpers restate `useField`'s own recipes over the core
23
+ API, so a store-level test sees exactly what a rendered input would —
24
+ without mounting one. Framework-agnostic; new Testing forms guide.
25
+ - **Variant sub-paths: `useVariantField`'s field argument is a PATH.**
26
+ `useVariantField(form, "payment", "billing.zip")` reaches inside a
27
+ variant-only object and `"span.0"` binds a variant-only tuple
28
+ position — the last shape the typed paths could not carry. The field
29
+ offer is `VariantFieldPath` (exported): every member's `FieldPath`,
30
+ minus paths rooted at a common key (those bind with plain `useField`,
31
+ the discriminant included), riding the same leaf classification,
32
+ tuple positionality, and depth budget as everywhere else. The value
33
+ distributes over the members that resolve the path, widened with
34
+ `| undefined` as before; `VariantFieldValue` now derives through
35
+ `FieldValue`, and the runtime is unchanged (the joined path always
36
+ worked — only its type was unreachable).
37
+
38
+ - **`useFormSteps(form, steps)`: the multi-step story.** One form, one
39
+ schema, and per-step validation scopes: `next()` validates the current
40
+ step's fields (async refines included; the full schema parses, so
41
+ cross-field rules inside a step fire, with errors scoped to the step)
42
+ and advances only on clean, marking errored fields touched exactly
43
+ like a failed submit; `back()` never validates; a forward `goTo()`
44
+ validates every step on the way and lands on the first one that
45
+ fails, so clickable step headers cannot skip past an invalid step.
46
+ `steps` is index-aligned live status (`name`, `visited`, `hasErrors`)
47
+ for progress UIs. The step index is React state on purpose — it is
48
+ navigation, not form data. `createFormHooks` binds it as
49
+ `use{Name}Steps`, and the docs gain a Multi-step forms guide.
50
+
3
51
  ## 0.19.0 — 2026-09-17
4
52
 
5
53
  ### Added
@@ -50,6 +98,21 @@
50
98
  back to the null-initial heuristic. **`isClearableSchema(schema)`** is
51
99
  the exported rule behind it, next to `emptyValueForSchema`.
52
100
 
101
+ ## formstand-cli Unreleased
102
+
103
+ ### Added
104
+
105
+ - **Containers inside union variants generate.** The generator's last
106
+ TODO class: an object or tuple nested in a variant flattens to its
107
+ scalar leaves, each bound as a dotted variant sub-path
108
+ (`useVariantField(form, path, "billing.zip")`, formstand 0.20+) — in
109
+ both layouts, every emission site (static unions, union rows, union
110
+ row-object fields, unions under nested arrays), with joined labels
111
+ ("Billing Zip") and the kit number-props hoists riding along. Depth
112
+ is judged on the full bound path (a too-deep leaf keeps the depth
113
+ TODO), and an array or union inside a variant still degrades — their
114
+ bindings need hooks and row machinery of their own.
115
+
53
116
  ## formstand-cli 0.18.0 — 2026-09-17
54
117
 
55
118
  ### Changed
@@ -0,0 +1,20 @@
1
+ // src/core/mode.ts
2
+ var shouldValidateOn = (trigger, mode, reValidateMode, submitAttempted, touched = false) => {
3
+ const effective = submitAttempted ? reValidateMode : mode;
4
+ switch (effective) {
5
+ case "onChange":
6
+ return trigger === "change";
7
+ case "onBlur":
8
+ return trigger === "blur";
9
+ case "onSubmit":
10
+ return false;
11
+ case "onTouched":
12
+ return trigger === "blur" || touched;
13
+ case "all":
14
+ return true;
15
+ }
16
+ };
17
+
18
+ export { shouldValidateOn };
19
+ //# sourceMappingURL=chunk-MQ6262S3.js.map
20
+ //# sourceMappingURL=chunk-MQ6262S3.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/mode.ts"],"names":[],"mappings":";AAWO,IAAM,mBAAmB,CAC9B,OAAA,EACA,MACA,cAAA,EACA,eAAA,EACA,UAAmB,KAAA,KACP;AACZ,EAAA,MAAM,SAAA,GAAY,kBAAkB,cAAA,GAAiB,IAAA;AACrD,EAAA,QAAQ,SAAA;AAAW,IACjB,KAAK,UAAA;AACH,MAAA,OAAO,OAAA,KAAY,QAAA;AAAA,IACrB,KAAK,QAAA;AACH,MAAA,OAAO,OAAA,KAAY,MAAA;AAAA,IACrB,KAAK,UAAA;AACH,MAAA,OAAO,KAAA;AAAA,IACT,KAAK,WAAA;AACH,MAAA,OAAO,YAAY,MAAA,IAAU,OAAA;AAAA,IAC/B,KAAK,KAAA;AACH,MAAA,OAAO,IAAA;AAAA;AAEb","file":"chunk-MQ6262S3.js","sourcesContent":["export type ValidationMode =\n | \"onChange\"\n | \"onBlur\"\n | \"onSubmit\"\n // Validate on blur always; on change only once the field has been touched.\n | \"onTouched\"\n // Validate on both change and blur.\n | \"all\";\n\nexport type ValidationTrigger = \"change\" | \"blur\";\n\nexport const shouldValidateOn = (\n trigger: ValidationTrigger,\n mode: ValidationMode,\n reValidateMode: ValidationMode,\n submitAttempted: boolean,\n touched: boolean = false,\n): boolean => {\n const effective = submitAttempted ? reValidateMode : mode;\n switch (effective) {\n case \"onChange\":\n return trigger === \"change\";\n case \"onBlur\":\n return trigger === \"blur\";\n case \"onSubmit\":\n return false;\n case \"onTouched\":\n return trigger === \"blur\" || touched;\n case \"all\":\n return true;\n }\n};\n"]}
package/dist/index.cjs CHANGED
@@ -4,9 +4,9 @@ var vanilla = require('zustand/vanilla');
4
4
  var middleware = require('zustand/middleware');
5
5
  var zod = require('zod');
6
6
  var react = require('react');
7
- var jsxRuntime = require('react/jsx-runtime');
8
7
  var react$1 = require('zustand/react');
9
8
  var shallow = require('zustand/react/shallow');
9
+ var jsxRuntime = require('react/jsx-runtime');
10
10
 
11
11
  // src/core/createForm.ts
12
12
 
@@ -1317,6 +1317,241 @@ var useForm = (schema, options) => {
1317
1317
  }, [form, schema, options.initialValues]);
1318
1318
  return form;
1319
1319
  };
1320
+
1321
+ // src/react/inputProps.ts
1322
+ var hasFieldError = (error) => error !== void 0 && error.length > 0;
1323
+ var ariaInvalid = (field) => hasFieldError(field.error) ? true : void 0;
1324
+ var numberToInputText = (value) => value === void 0 || value === null || Number.isNaN(value) ? "" : String(value);
1325
+ var parseNumberText = (text) => {
1326
+ if (text.trim() === "") return { kind: "empty" };
1327
+ const parsed = Number(text);
1328
+ return Number.isFinite(parsed) ? { kind: "number", value: parsed } : { kind: "invalid" };
1329
+ };
1330
+ var textInputProps = (field) => ({
1331
+ name: field.path,
1332
+ value: field.value ?? "",
1333
+ "aria-invalid": ariaInvalid(field),
1334
+ onChange: (e) => {
1335
+ const text = e.target.value;
1336
+ field.setValue(text === "" && field.clearable ? field.emptyValue : text);
1337
+ },
1338
+ onBlur: field.onBlur
1339
+ });
1340
+ var numberInputProps = (field) => ({
1341
+ type: "number",
1342
+ name: field.path,
1343
+ value: numberToInputText(field.value),
1344
+ "aria-invalid": ariaInvalid(field),
1345
+ onChange: (e) => {
1346
+ const parsed = parseNumberText(e.target.value);
1347
+ if (parsed.kind === "invalid") return;
1348
+ field.setValue(
1349
+ parsed.kind === "number" ? parsed.value : field.emptyValue
1350
+ );
1351
+ },
1352
+ onBlur: field.onBlur
1353
+ });
1354
+ var dateToInputText = (value) => {
1355
+ if (value === void 0 || value === null || Number.isNaN(value.getTime())) {
1356
+ return "";
1357
+ }
1358
+ const pad = (part) => String(part).padStart(2, "0");
1359
+ return `${String(value.getFullYear()).padStart(4, "0")}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
1360
+ };
1361
+ var parseDateText = (text) => {
1362
+ if (text.trim() === "") return { kind: "empty" };
1363
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
1364
+ if (match === null) return { kind: "invalid" };
1365
+ const [, year, month, day] = match;
1366
+ const y = Number(year);
1367
+ const date = new Date(y, Number(month) - 1, Number(day));
1368
+ if (y < 100) date.setFullYear(y);
1369
+ return date.getFullYear() === y && date.getMonth() === Number(month) - 1 && date.getDate() === Number(day) ? { kind: "date", value: date } : { kind: "invalid" };
1370
+ };
1371
+ var dateInputProps = (field) => ({
1372
+ type: "date",
1373
+ name: field.path,
1374
+ value: dateToInputText(field.value),
1375
+ "aria-invalid": ariaInvalid(field),
1376
+ onChange: (e) => {
1377
+ const parsed = parseDateText(e.target.value);
1378
+ if (parsed.kind !== "date") {
1379
+ field.setValue(field.emptyValue);
1380
+ return;
1381
+ }
1382
+ const current = field.value;
1383
+ const next = current instanceof Date && !Number.isNaN(current.getTime()) ? new Date(
1384
+ parsed.value.getFullYear(),
1385
+ parsed.value.getMonth(),
1386
+ parsed.value.getDate(),
1387
+ current.getHours(),
1388
+ current.getMinutes(),
1389
+ current.getSeconds(),
1390
+ current.getMilliseconds()
1391
+ ) : parsed.value;
1392
+ field.setValue(next);
1393
+ },
1394
+ onBlur: field.onBlur
1395
+ });
1396
+ var checkboxProps = (field) => ({
1397
+ type: "checkbox",
1398
+ name: field.path,
1399
+ checked: field.value ?? false,
1400
+ "aria-invalid": ariaInvalid(field),
1401
+ onChange: (e) => field.setValue(e.target.checked),
1402
+ onBlur: field.onBlur
1403
+ });
1404
+ var selectProps = (field) => ({
1405
+ name: field.path,
1406
+ value: field.value ?? "",
1407
+ "aria-invalid": ariaInvalid(field),
1408
+ onChange: (e) => {
1409
+ const v = e.target.value;
1410
+ field.setValue(v === "" && field.clearable ? field.emptyValue : v);
1411
+ },
1412
+ onBlur: field.onBlur
1413
+ });
1414
+
1415
+ // src/react/useMaskedInput.ts
1416
+ var useMaskedInput = (field, options) => {
1417
+ const [edit, setEdit] = react.useState({
1418
+ raw: null,
1419
+ pushed: void 0
1420
+ });
1421
+ const externallyChanged = edit.raw !== null && !Object.is(field.value, edit.pushed);
1422
+ if (externallyChanged) {
1423
+ setEdit({ raw: null, pushed: void 0 });
1424
+ }
1425
+ const raw = externallyChanged ? null : edit.raw;
1426
+ return {
1427
+ name: field.path,
1428
+ value: raw ?? (field.value === void 0 || field.value === null ? "" : options.format(field.value)),
1429
+ "aria-invalid": hasFieldError(field.error) ? true : void 0,
1430
+ onChange: (e) => {
1431
+ const text = e.target.value;
1432
+ const parsed = options.parse(text);
1433
+ switch (parsed.kind) {
1434
+ case "value":
1435
+ setEdit({ raw: text, pushed: parsed.value });
1436
+ field.setValue(parsed.value);
1437
+ return;
1438
+ case "empty": {
1439
+ const empty = field.emptyValue;
1440
+ setEdit({ raw: text, pushed: empty });
1441
+ field.setValue(empty);
1442
+ return;
1443
+ }
1444
+ case "invalid":
1445
+ setEdit({ raw: text, pushed: field.value });
1446
+ return;
1447
+ }
1448
+ },
1449
+ onBlur: () => {
1450
+ setEdit({ raw: null, pushed: void 0 });
1451
+ field.onBlur();
1452
+ }
1453
+ };
1454
+ };
1455
+ function useFormSelector(form, selector) {
1456
+ return react$1.useStore(form.store, selector);
1457
+ }
1458
+ function useFormSelectorShallow(form, selector) {
1459
+ return react$1.useStore(form.store, shallow.useShallow(selector));
1460
+ }
1461
+
1462
+ // src/react/useFormSteps.ts
1463
+ var useFormSteps = (form, steps) => {
1464
+ const [step, setStep] = react.useState(0);
1465
+ const [visited, setVisited] = react.useState(
1466
+ () => /* @__PURE__ */ new Set([0])
1467
+ );
1468
+ const latest = react.useRef(steps);
1469
+ react.useEffect(() => {
1470
+ latest.current = steps;
1471
+ });
1472
+ const validateStep = react.useCallback(
1473
+ async (index) => {
1474
+ const definition = latest.current[index];
1475
+ if (definition === void 0) return true;
1476
+ const result = await form.validateFieldsAsync(definition.fields);
1477
+ if (result.kind === "invalid") {
1478
+ Object.keys(result.errors).forEach((key) => {
1479
+ form.setTouched(key, true);
1480
+ });
1481
+ return false;
1482
+ }
1483
+ return true;
1484
+ },
1485
+ [form]
1486
+ );
1487
+ const next = react.useCallback(async () => {
1488
+ const ok = await validateStep(step);
1489
+ if (!ok) return false;
1490
+ const target = Math.min(step + 1, latest.current.length - 1);
1491
+ setStep(target);
1492
+ setVisited((prev) => /* @__PURE__ */ new Set([...prev, target]));
1493
+ return true;
1494
+ }, [validateStep, step]);
1495
+ const back = react.useCallback(() => {
1496
+ setStep((prev) => Math.max(prev - 1, 0));
1497
+ }, []);
1498
+ const goTo = react.useCallback(
1499
+ async (target) => {
1500
+ const count = latest.current.length;
1501
+ const clamped = Math.max(0, Math.min(target, count - 1));
1502
+ if (clamped <= step) {
1503
+ setStep(clamped);
1504
+ return clamped;
1505
+ }
1506
+ const walk = async (from) => {
1507
+ if (from >= clamped) return clamped;
1508
+ return await validateStep(from) ? walk(from + 1) : from;
1509
+ };
1510
+ const landed = await walk(step);
1511
+ setStep(landed);
1512
+ setVisited(
1513
+ (prev) => /* @__PURE__ */ new Set([
1514
+ ...prev,
1515
+ ...Array.from({ length: landed - step + 1 }, (_, i) => step + i)
1516
+ ])
1517
+ );
1518
+ return landed;
1519
+ },
1520
+ [validateStep, step]
1521
+ );
1522
+ const errorFlags = useFormSelectorShallow(
1523
+ form,
1524
+ (state) => steps.map(
1525
+ (definition) => definition.fields.some(
1526
+ (path) => Object.keys(state.errors).some((key) => isPathOrChild(key, path))
1527
+ )
1528
+ )
1529
+ );
1530
+ const statuses = react.useMemo(
1531
+ () => steps.map(
1532
+ (definition, index) => ({
1533
+ name: definition.name,
1534
+ visited: visited.has(index),
1535
+ hasErrors: errorFlags[index] ?? false
1536
+ })
1537
+ ),
1538
+ [steps, visited, errorFlags]
1539
+ );
1540
+ return react.useMemo(
1541
+ () => ({
1542
+ step,
1543
+ name: steps[step]?.name,
1544
+ count: steps.length,
1545
+ isFirst: step === 0,
1546
+ isLast: step === steps.length - 1,
1547
+ next,
1548
+ back,
1549
+ goTo,
1550
+ steps: statuses
1551
+ }),
1552
+ [step, steps, next, back, goTo, statuses]
1553
+ );
1554
+ };
1320
1555
  var warnUnobserved = (result, options, site) => {
1321
1556
  if (result.kind === "error" && options?.onError === void 0 && process.env.NODE_ENV !== "production") {
1322
1557
  console.warn(
@@ -1785,12 +2020,6 @@ function useIsValidating(form, path) {
1785
2020
  }
1786
2021
  var useIsSubmitting = (form) => react$1.useStore(form.store, (state) => state.isSubmitting);
1787
2022
  var useSubmitCount = (form) => react$1.useStore(form.store, (state) => state.submitCount);
1788
- function useFormSelector(form, selector) {
1789
- return react$1.useStore(form.store, selector);
1790
- }
1791
- function useFormSelectorShallow(form, selector) {
1792
- return react$1.useStore(form.store, shallow.useShallow(selector));
1793
- }
1794
2023
 
1795
2024
  // src/react/createFormHooks.ts
1796
2025
  var capitalize = (name) => name.length === 0 ? name : name.charAt(0).toUpperCase() + name.slice(1);
@@ -1816,6 +2045,7 @@ var createFormHooks = (form, name) => {
1816
2045
  paths
1817
2046
  );
1818
2047
  const useBoundSubmitCount = () => useSubmitCount(structural);
2048
+ const useBoundSteps = (steps) => useFormSteps(form, steps);
1819
2049
  const prefix = capitalize(name ?? "");
1820
2050
  return {
1821
2051
  [`use${prefix}Field`]: useBoundField,
@@ -1830,103 +2060,10 @@ var createFormHooks = (form, name) => {
1830
2060
  [`use${prefix}IsSubmitting`]: useBoundIsSubmitting,
1831
2061
  [`use${prefix}SubmitCount`]: useBoundSubmitCount,
1832
2062
  [`use${prefix}Values`]: useBoundValues,
1833
- [`use${prefix}Fields`]: useBoundFields
2063
+ [`use${prefix}Fields`]: useBoundFields,
2064
+ [`use${prefix}Steps`]: useBoundSteps
1834
2065
  };
1835
2066
  };
1836
-
1837
- // src/react/inputProps.ts
1838
- var hasFieldError = (error) => error !== void 0 && error.length > 0;
1839
- var ariaInvalid = (field) => hasFieldError(field.error) ? true : void 0;
1840
- var numberToInputText = (value) => value === void 0 || value === null || Number.isNaN(value) ? "" : String(value);
1841
- var parseNumberText = (text) => {
1842
- if (text.trim() === "") return { kind: "empty" };
1843
- const parsed = Number(text);
1844
- return Number.isFinite(parsed) ? { kind: "number", value: parsed } : { kind: "invalid" };
1845
- };
1846
- var textInputProps = (field) => ({
1847
- name: field.path,
1848
- value: field.value ?? "",
1849
- "aria-invalid": ariaInvalid(field),
1850
- onChange: (e) => {
1851
- const text = e.target.value;
1852
- field.setValue(text === "" && field.clearable ? field.emptyValue : text);
1853
- },
1854
- onBlur: field.onBlur
1855
- });
1856
- var numberInputProps = (field) => ({
1857
- type: "number",
1858
- name: field.path,
1859
- value: numberToInputText(field.value),
1860
- "aria-invalid": ariaInvalid(field),
1861
- onChange: (e) => {
1862
- const parsed = parseNumberText(e.target.value);
1863
- if (parsed.kind === "invalid") return;
1864
- field.setValue(
1865
- parsed.kind === "number" ? parsed.value : field.emptyValue
1866
- );
1867
- },
1868
- onBlur: field.onBlur
1869
- });
1870
- var dateToInputText = (value) => {
1871
- if (value === void 0 || value === null || Number.isNaN(value.getTime())) {
1872
- return "";
1873
- }
1874
- const pad = (part) => String(part).padStart(2, "0");
1875
- return `${String(value.getFullYear()).padStart(4, "0")}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
1876
- };
1877
- var parseDateText = (text) => {
1878
- if (text.trim() === "") return { kind: "empty" };
1879
- const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
1880
- if (match === null) return { kind: "invalid" };
1881
- const [, year, month, day] = match;
1882
- const y = Number(year);
1883
- const date = new Date(y, Number(month) - 1, Number(day));
1884
- if (y < 100) date.setFullYear(y);
1885
- return date.getFullYear() === y && date.getMonth() === Number(month) - 1 && date.getDate() === Number(day) ? { kind: "date", value: date } : { kind: "invalid" };
1886
- };
1887
- var dateInputProps = (field) => ({
1888
- type: "date",
1889
- name: field.path,
1890
- value: dateToInputText(field.value),
1891
- "aria-invalid": ariaInvalid(field),
1892
- onChange: (e) => {
1893
- const parsed = parseDateText(e.target.value);
1894
- if (parsed.kind !== "date") {
1895
- field.setValue(field.emptyValue);
1896
- return;
1897
- }
1898
- const current = field.value;
1899
- const next = current instanceof Date && !Number.isNaN(current.getTime()) ? new Date(
1900
- parsed.value.getFullYear(),
1901
- parsed.value.getMonth(),
1902
- parsed.value.getDate(),
1903
- current.getHours(),
1904
- current.getMinutes(),
1905
- current.getSeconds(),
1906
- current.getMilliseconds()
1907
- ) : parsed.value;
1908
- field.setValue(next);
1909
- },
1910
- onBlur: field.onBlur
1911
- });
1912
- var checkboxProps = (field) => ({
1913
- type: "checkbox",
1914
- name: field.path,
1915
- checked: field.value ?? false,
1916
- "aria-invalid": ariaInvalid(field),
1917
- onChange: (e) => field.setValue(e.target.checked),
1918
- onBlur: field.onBlur
1919
- });
1920
- var selectProps = (field) => ({
1921
- name: field.path,
1922
- value: field.value ?? "",
1923
- "aria-invalid": ariaInvalid(field),
1924
- onChange: (e) => {
1925
- const v = e.target.value;
1926
- field.setValue(v === "" && field.clearable ? field.emptyValue : v);
1927
- },
1928
- onBlur: field.onBlur
1929
- });
1930
2067
  var useFieldA11y = (error) => {
1931
2068
  const id = react.useId();
1932
2069
  const errorId = `${id}-error`;
@@ -2257,11 +2394,13 @@ exports.useFormActionState = useFormActionState;
2257
2394
  exports.useFormError = useFormError;
2258
2395
  exports.useFormSelector = useFormSelector;
2259
2396
  exports.useFormSelectorShallow = useFormSelectorShallow;
2397
+ exports.useFormSteps = useFormSteps;
2260
2398
  exports.useFormValues = useFormValues;
2261
2399
  exports.useIsDirty = useIsDirty;
2262
2400
  exports.useIsSubmitting = useIsSubmitting;
2263
2401
  exports.useIsValid = useIsValid;
2264
2402
  exports.useIsValidating = useIsValidating;
2403
+ exports.useMaskedInput = useMaskedInput;
2265
2404
  exports.useNumberInput = useNumberInput;
2266
2405
  exports.usePersistForm = usePersistForm;
2267
2406
  exports.useSubmitCount = useSubmitCount;