formstand 0.16.0 → 0.18.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.cjs CHANGED
@@ -276,6 +276,7 @@ var emptyValueForSchema = (schema) => {
276
276
  };
277
277
  return walk(schema, false);
278
278
  };
279
+ var isClearableSchema = (schema) => schema instanceof zod.z.ZodOptional || schema instanceof zod.z.ZodNullable || schema instanceof zod.z.ZodDefault || schema instanceof zod.z.ZodPrefault;
279
280
  var unwrapForWalk = (s) => {
280
281
  if (s instanceof zod.z.ZodOptional || s instanceof zod.z.ZodNullable) {
281
282
  return s.unwrap();
@@ -297,7 +298,7 @@ var schemaHasPath = (schema, path) => {
297
298
  if (head === void 0) return true;
298
299
  if (s instanceof zod.z.ZodObject) {
299
300
  const shape = s.shape;
300
- const child = shape[String(head)];
301
+ const child = Object.hasOwn(shape, String(head)) ? shape[String(head)] : void 0;
301
302
  if (child !== void 0) return walk(child, rest, budget - 1);
302
303
  const catchall = s.def.catchall;
303
304
  return catchall !== void 0 && !(catchall instanceof zod.z.ZodNever);
@@ -317,7 +318,7 @@ var fieldSchemaAtPath = (schema, path) => parsePath(path).reduce((current, segme
317
318
  if (current === null || hasChecks(current)) return null;
318
319
  if (current instanceof zod.z.ZodObject && typeof segment === "string") {
319
320
  const shape = current.shape;
320
- return shape[segment] ?? null;
321
+ return Object.hasOwn(shape, segment) ? shape[segment] ?? null : null;
321
322
  }
322
323
  if (current instanceof zod.z.ZodArray && typeof segment === "number") {
323
324
  return current.element;
@@ -722,7 +723,7 @@ var createForm = (schema, options) => {
722
723
  };
723
724
  const applyArrayOp = (path, nextArray, mapper, indexInBounds) => {
724
725
  const current = getAtPath(store.getState().values, path);
725
- if (current !== void 0 && !Array.isArray(current)) {
726
+ if (current !== void 0 && current !== null && !Array.isArray(current)) {
726
727
  console.warn(
727
728
  `[formstand] array op on "${path}" but the value at that path is not an array (got ${typeof current}). Operation skipped.`
728
729
  );
@@ -831,6 +832,7 @@ var createForm = (schema, options) => {
831
832
  try {
832
833
  await onValid(result.data);
833
834
  } catch (error) {
835
+ submitOptions?.onError?.(error);
834
836
  return { kind: "error", error };
835
837
  }
836
838
  return { kind: "valid", data: result.data };
@@ -938,6 +940,17 @@ var createForm = (schema, options) => {
938
940
  ...state,
939
941
  ...errorChannels(state, { serverErrors: errors })
940
942
  })),
943
+ addErrors: (errors) => store.setState((state) => ({
944
+ ...state,
945
+ ...errorChannels(state, {
946
+ serverErrors: Object.entries(errors).reduce(
947
+ // Computed keys throughout, so a server field named
948
+ // "__proto__" lands as an own property like everywhere else.
949
+ (acc, [key, list]) => list === void 0 || list.length === 0 ? omitKey(acc, key) : { ...acc, [key]: list },
950
+ state.serverErrors
951
+ )
952
+ })
953
+ })),
941
954
  clearErrors: (path) => store.setState((state) => {
942
955
  const scrub = (map) => path === void 0 ? emptyErrors : omitScope(map, [path]);
943
956
  return {
@@ -1077,7 +1090,14 @@ var createForm = (schema, options) => {
1077
1090
  ),
1078
1091
  handleSubmit: (onValid, onInvalid, opts) => async (event) => {
1079
1092
  event?.preventDefault();
1080
- return submit(onValid, onInvalid, opts);
1093
+ const result = await submit(onValid, onInvalid, opts);
1094
+ if (result.kind === "error" && opts?.onError === void 0 && process.env.NODE_ENV !== "production") {
1095
+ console.warn(
1096
+ "[formstand] submit handler threw and nothing observed it \u2014 the form shows no feedback. Pass { onError } to handleSubmit (or setError inside your handler):",
1097
+ result.error
1098
+ );
1099
+ }
1100
+ return result;
1081
1101
  },
1082
1102
  diff: () => {
1083
1103
  const state = store.getState();
@@ -1178,13 +1198,16 @@ var persistForm = (form, options) => {
1178
1198
  return void 0;
1179
1199
  }
1180
1200
  };
1181
- const restore = () => {
1201
+ const load = () => {
1182
1202
  try {
1183
1203
  const raw = storage === null ? null : storage.getItem(options.key);
1184
1204
  if (raw === null) return false;
1185
1205
  const parsed = JSON.parse(raw);
1186
1206
  const versioned = options.version !== void 0 && typeof parsed === "object" && parsed !== null && "__v" in parsed;
1187
1207
  if (options.version !== void 0 && !versioned) return false;
1208
+ if (options.version === void 0 && typeof parsed === "object" && parsed !== null && Object.hasOwn(parsed, "__v") && Object.hasOwn(parsed, "values")) {
1209
+ return false;
1210
+ }
1188
1211
  const wrapper = parsed;
1189
1212
  if (versioned && wrapper.__v !== options.version) return false;
1190
1213
  if (versioned && wrapper.values === void 0) return false;
@@ -1220,7 +1243,7 @@ var persistForm = (form, options) => {
1220
1243
  }
1221
1244
  };
1222
1245
  if (apply !== "manual") {
1223
- restore();
1246
+ load();
1224
1247
  }
1225
1248
  const unsubscribe = form.watchValues((values) => {
1226
1249
  cancelPending();
@@ -1234,7 +1257,7 @@ var persistForm = (form, options) => {
1234
1257
  }
1235
1258
  });
1236
1259
  return Object.freeze({
1237
- restore,
1260
+ load,
1238
1261
  clear: () => {
1239
1262
  cancelPending();
1240
1263
  try {
@@ -1248,6 +1271,33 @@ var persistForm = (form, options) => {
1248
1271
  }
1249
1272
  });
1250
1273
  };
1274
+ var usePersistForm = (form, options) => {
1275
+ const inner = react.useRef(null);
1276
+ const latestOptions = react.useRef(options);
1277
+ react.useEffect(() => {
1278
+ latestOptions.current = options;
1279
+ });
1280
+ const key = options.key;
1281
+ react.useEffect(() => {
1282
+ const handle = persistForm(form, latestOptions.current);
1283
+ inner.current = handle;
1284
+ return () => {
1285
+ inner.current = null;
1286
+ handle.dispose();
1287
+ };
1288
+ }, [form, key]);
1289
+ return react.useMemo(
1290
+ () => ({
1291
+ load: () => inner.current?.load() ?? false,
1292
+ clear: () => inner.current?.clear(),
1293
+ // dispose stops THIS component's watching early; the effect cleanup
1294
+ // still runs its own dispose harmlessly (persistForm's dispose is
1295
+ // idempotent by construction — it only cancels and unsubscribes).
1296
+ dispose: () => inner.current?.dispose()
1297
+ }),
1298
+ []
1299
+ );
1300
+ };
1251
1301
  var useForm = (schema, options) => {
1252
1302
  const formRef = react.useRef(null);
1253
1303
  if (formRef.current === null) {
@@ -1302,12 +1352,17 @@ function useField(form, pathArg, options) {
1302
1352
  const path = slice.path;
1303
1353
  const formSchema = form.schema;
1304
1354
  const initialValue = slice.initialValue;
1305
- const emptyValue = react.useMemo(() => {
1355
+ const { emptyValue, clearable } = react.useMemo(() => {
1306
1356
  if (formSchema !== void 0) {
1307
1357
  const sub = fieldSchemaAtPath(formSchema, path);
1308
- if (sub !== null) return emptyValueForSchema(sub);
1358
+ if (sub !== null) {
1359
+ return {
1360
+ emptyValue: emptyValueForSchema(sub),
1361
+ clearable: isClearableSchema(sub)
1362
+ };
1363
+ }
1309
1364
  }
1310
- return initialValue === null ? null : void 0;
1365
+ return initialValue === null ? { emptyValue: null, clearable: true } : { emptyValue: void 0, clearable: false };
1311
1366
  }, [formSchema, path, initialValue]);
1312
1367
  const debounceMs = options?.debounceMs;
1313
1368
  const debounceTimerRef = react.useRef(null);
@@ -1324,9 +1379,13 @@ function useField(form, pathArg, options) {
1324
1379
  if (debounceTimerRef.current !== null) {
1325
1380
  clearTimeout(debounceTimerRef.current);
1326
1381
  }
1382
+ const scheduled = getAtPath(form.store.getState().values, path);
1327
1383
  debounceTimerRef.current = setTimeout(() => {
1328
- void form.validateFieldAsync(path);
1329
1384
  debounceTimerRef.current = null;
1385
+ const state = form.store.getState();
1386
+ if (state.values === state.initialValues) return;
1387
+ if (!Object.is(getAtPath(state.values, path), scheduled)) return;
1388
+ void form.validateFieldAsync(path);
1330
1389
  }, debounceMs);
1331
1390
  return;
1332
1391
  }
@@ -1389,6 +1448,7 @@ function useField(form, pathArg, options) {
1389
1448
  value: slice.value,
1390
1449
  initialValue: slice.initialValue,
1391
1450
  emptyValue,
1451
+ clearable,
1392
1452
  error: slice.error,
1393
1453
  // `?.[0]` covers both the undefined channel and an empty array.
1394
1454
  firstError: slice.error?.[0],
@@ -1403,7 +1463,7 @@ function useField(form, pathArg, options) {
1403
1463
  validateAsync: validateAsync2,
1404
1464
  onBlur
1405
1465
  }),
1406
- [slice, emptyValue, setValue, setTouched, setError, clearError, validate, validateAsync2, onBlur]
1466
+ [slice, emptyValue, clearable, setValue, setTouched, setError, clearError, validate, validateAsync2, onBlur]
1407
1467
  );
1408
1468
  }
1409
1469
 
@@ -1587,25 +1647,64 @@ function useFieldArray(form, pathArg) {
1587
1647
  })),
1588
1648
  [items, ids]
1589
1649
  );
1650
+ const setError = react.useCallback(
1651
+ // Same string-shorthand normalization as useField.setError, so custom
1652
+ // implementations typed for readonly string[] never receive a bare
1653
+ // string.
1654
+ (errors) => form.setError?.(path, typeof errors === "string" ? [errors] : errors),
1655
+ [form, path]
1656
+ );
1657
+ const clearError = react.useCallback(() => form.clearErrors?.(path), [form, path]);
1590
1658
  return react.useMemo(
1591
1659
  () => ({
1660
+ path,
1592
1661
  fields,
1593
1662
  items,
1594
1663
  length: items.length,
1595
1664
  error,
1665
+ firstError: error?.[0],
1666
+ setError,
1667
+ clearError,
1596
1668
  push,
1597
1669
  remove,
1598
1670
  insert,
1599
1671
  move,
1600
1672
  swap
1601
1673
  }),
1602
- [fields, items, error, push, remove, insert, move, swap]
1674
+ [path, fields, items, error, setError, clearError, push, remove, insert, move, swap]
1603
1675
  );
1604
1676
  }
1605
1677
  var useFormError = (form) => react$1.useStore(
1606
1678
  form.store,
1607
1679
  shallow.useShallow((state) => state.errors[""])
1608
1680
  );
1681
+ function useFormValues(form) {
1682
+ return react$1.useStore(form.store, (state) => state.values);
1683
+ }
1684
+
1685
+ // src/react/useFields.ts
1686
+ function useFields(form, paths) {
1687
+ const fields = paths.map(
1688
+ (path) => (
1689
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- a fixed hook count by the stable-arity contract above; each element must be a REAL UseFieldReturn so it spreads into the prop builders
1690
+ useField(form, path)
1691
+ )
1692
+ );
1693
+ const merged = [...new Set(fields.flatMap((field) => field.error ?? []))];
1694
+ return {
1695
+ fields,
1696
+ error: merged.length > 0 ? merged : void 0,
1697
+ firstError: merged[0],
1698
+ touched: fields.some((field) => field.touched),
1699
+ dirty: fields.some((field) => field.dirty),
1700
+ isValidating: fields.some((field) => field.isValidating),
1701
+ onBlur: () => {
1702
+ fields.forEach((field) => {
1703
+ field.onBlur();
1704
+ });
1705
+ }
1706
+ };
1707
+ }
1609
1708
  function useIsDirty(form, path) {
1610
1709
  return react$1.useStore(
1611
1710
  form.store,
@@ -1626,6 +1725,19 @@ function useIsValid(form, path) {
1626
1725
  return true;
1627
1726
  });
1628
1727
  }
1728
+ function useIsValidating(form, path) {
1729
+ return react$1.useStore(form.store, (state) => {
1730
+ if (path === void 0 || path === "") {
1731
+ if (state.isValidatingForm) return true;
1732
+ }
1733
+ for (const k in state.isValidating) {
1734
+ if (state.isValidating[k] === true && (path === void 0 || path === "" || isPathOrChild(k, path))) {
1735
+ return true;
1736
+ }
1737
+ }
1738
+ return false;
1739
+ });
1740
+ }
1629
1741
  var useIsSubmitting = (form) => react$1.useStore(form.store, (state) => state.isSubmitting);
1630
1742
  var useSubmitCount = (form) => react$1.useStore(form.store, (state) => state.submitCount);
1631
1743
  function useFormSelector(form, selector) {
@@ -1651,7 +1763,13 @@ var createFormHooks = (form, name) => {
1651
1763
  const useBoundError = () => useFormError(structural);
1652
1764
  const useBoundIsDirty = (path) => useIsDirty(structural, path);
1653
1765
  const useBoundIsValid = (path) => useIsValid(structural, path);
1766
+ const useBoundIsValidating = (path) => useIsValidating(structural, path);
1654
1767
  const useBoundIsSubmitting = () => useIsSubmitting(structural);
1768
+ const useBoundValues = () => useFormValues(structural);
1769
+ const useBoundFields = (paths) => useFields(
1770
+ structural,
1771
+ paths
1772
+ );
1655
1773
  const useBoundSubmitCount = () => useSubmitCount(structural);
1656
1774
  const prefix = capitalize(name ?? "");
1657
1775
  return {
@@ -1663,37 +1781,13 @@ var createFormHooks = (form, name) => {
1663
1781
  [`use${prefix}Error`]: useBoundError,
1664
1782
  [`use${prefix}IsDirty`]: useBoundIsDirty,
1665
1783
  [`use${prefix}IsValid`]: useBoundIsValid,
1784
+ [`use${prefix}IsValidating`]: useBoundIsValidating,
1666
1785
  [`use${prefix}IsSubmitting`]: useBoundIsSubmitting,
1667
- [`use${prefix}SubmitCount`]: useBoundSubmitCount
1786
+ [`use${prefix}SubmitCount`]: useBoundSubmitCount,
1787
+ [`use${prefix}Values`]: useBoundValues,
1788
+ [`use${prefix}Fields`]: useBoundFields
1668
1789
  };
1669
1790
  };
1670
- function useFormValues(form) {
1671
- return react$1.useStore(form.store, (state) => state.values);
1672
- }
1673
-
1674
- // src/react/useFields.ts
1675
- function useFields(form, paths) {
1676
- const fields = paths.map(
1677
- (path) => (
1678
- // eslint-disable-next-line react-hooks/rules-of-hooks -- a fixed hook count by the stable-arity contract above; each element must be a REAL UseFieldReturn so it spreads into the prop builders
1679
- useField(form, path)
1680
- )
1681
- );
1682
- const merged = [...new Set(fields.flatMap((field) => field.error ?? []))];
1683
- return {
1684
- fields,
1685
- error: merged.length > 0 ? merged : void 0,
1686
- firstError: merged[0],
1687
- touched: fields.some((field) => field.touched),
1688
- dirty: fields.some((field) => field.dirty),
1689
- isValidating: fields.some((field) => field.isValidating),
1690
- onBlur: () => {
1691
- fields.forEach((field) => {
1692
- field.onBlur();
1693
- });
1694
- }
1695
- };
1696
- }
1697
1791
 
1698
1792
  // src/react/inputProps.ts
1699
1793
  var hasFieldError = (error) => error !== void 0 && error.length > 0;
@@ -1710,9 +1804,7 @@ var textInputProps = (field) => ({
1710
1804
  "aria-invalid": ariaInvalid(field),
1711
1805
  onChange: (e) => {
1712
1806
  const text = e.target.value;
1713
- field.setValue(
1714
- text === "" && field.emptyValue === null ? null : text
1715
- );
1807
+ field.setValue(text === "" && field.clearable ? field.emptyValue : text);
1716
1808
  },
1717
1809
  onBlur: field.onBlur
1718
1810
  });
@@ -1723,6 +1815,7 @@ var numberInputProps = (field) => ({
1723
1815
  "aria-invalid": ariaInvalid(field),
1724
1816
  onChange: (e) => {
1725
1817
  const parsed = parseNumberText(e.target.value);
1818
+ if (parsed.kind === "invalid") return;
1726
1819
  field.setValue(
1727
1820
  parsed.kind === "number" ? parsed.value : field.emptyValue
1728
1821
  );
@@ -1785,9 +1878,7 @@ var selectProps = (field) => ({
1785
1878
  "aria-invalid": ariaInvalid(field),
1786
1879
  onChange: (e) => {
1787
1880
  const v = e.target.value;
1788
- field.setValue(
1789
- v === "" && field.emptyValue === null ? null : v
1790
- );
1881
+ field.setValue(v === "" && field.clearable ? field.emptyValue : v);
1791
1882
  },
1792
1883
  onBlur: field.onBlur
1793
1884
  });
@@ -1994,7 +2085,7 @@ var SelectField = ({
1994
2085
  const field = useField(form, path);
1995
2086
  const a11y = useFieldA11y(field.error);
1996
2087
  const select = selectProps(field);
1997
- const clearable = field.emptyValue === null;
2088
+ const clearable = field.clearable;
1998
2089
  const hasExplicitEmptyOption = options.some((opt) => opt.value === "");
1999
2090
  const showEmptyOption = !hasExplicitEmptyOption && (clearable || select.value === "" || placeholder !== void 0);
2000
2091
  return /* @__PURE__ */ jsxRuntime.jsx(
@@ -2101,6 +2192,7 @@ exports.focusFirstError = focusFirstError;
2101
2192
  exports.getAtPath = getAtPath;
2102
2193
  exports.hasFieldError = hasFieldError;
2103
2194
  exports.isAsyncRequiredError = isAsyncRequiredError;
2195
+ exports.isClearableSchema = isClearableSchema;
2104
2196
  exports.numberInputProps = numberInputProps;
2105
2197
  exports.numberToInputText = numberToInputText;
2106
2198
  exports.parseDateText = parseDateText;
@@ -2122,7 +2214,9 @@ exports.useFormValues = useFormValues;
2122
2214
  exports.useIsDirty = useIsDirty;
2123
2215
  exports.useIsSubmitting = useIsSubmitting;
2124
2216
  exports.useIsValid = useIsValid;
2217
+ exports.useIsValidating = useIsValidating;
2125
2218
  exports.useNumberInput = useNumberInput;
2219
+ exports.usePersistForm = usePersistForm;
2126
2220
  exports.useSubmitCount = useSubmitCount;
2127
2221
  exports.useVariantField = useVariantField;
2128
2222
  exports.validateAsync = validateAsync;