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.js CHANGED
@@ -3,7 +3,7 @@ export { getAtPath, parsePath, setAtPath, useFormSelector, useFormSelectorShallo
3
3
  import { createStore } from 'zustand/vanilla';
4
4
  import { devtools } from 'zustand/middleware';
5
5
  import { z } from 'zod';
6
- import { useRef, useEffect, createContext, useMemo, useCallback, useState, useContext, useId } from 'react';
6
+ import { useRef, useEffect, useMemo, createContext, useCallback, useState, useContext, useId } from 'react';
7
7
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
8
8
  import { useStore } from 'zustand/react';
9
9
  import { useShallow } from 'zustand/react/shallow';
@@ -142,6 +142,7 @@ var emptyValueForSchema = (schema) => {
142
142
  };
143
143
  return walk(schema, false);
144
144
  };
145
+ var isClearableSchema = (schema) => schema instanceof z.ZodOptional || schema instanceof z.ZodNullable || schema instanceof z.ZodDefault || schema instanceof z.ZodPrefault;
145
146
  var unwrapForWalk = (s) => {
146
147
  if (s instanceof z.ZodOptional || s instanceof z.ZodNullable) {
147
148
  return s.unwrap();
@@ -163,7 +164,7 @@ var schemaHasPath = (schema, path) => {
163
164
  if (head === void 0) return true;
164
165
  if (s instanceof z.ZodObject) {
165
166
  const shape = s.shape;
166
- const child = shape[String(head)];
167
+ const child = Object.hasOwn(shape, String(head)) ? shape[String(head)] : void 0;
167
168
  if (child !== void 0) return walk(child, rest, budget - 1);
168
169
  const catchall = s.def.catchall;
169
170
  return catchall !== void 0 && !(catchall instanceof z.ZodNever);
@@ -183,7 +184,7 @@ var fieldSchemaAtPath = (schema, path) => parsePath(path).reduce((current, segme
183
184
  if (current === null || hasChecks(current)) return null;
184
185
  if (current instanceof z.ZodObject && typeof segment === "string") {
185
186
  const shape = current.shape;
186
- return shape[segment] ?? null;
187
+ return Object.hasOwn(shape, segment) ? shape[segment] ?? null : null;
187
188
  }
188
189
  if (current instanceof z.ZodArray && typeof segment === "number") {
189
190
  return current.element;
@@ -588,7 +589,7 @@ var createForm = (schema, options) => {
588
589
  };
589
590
  const applyArrayOp = (path, nextArray, mapper, indexInBounds) => {
590
591
  const current = getAtPath(store.getState().values, path);
591
- if (current !== void 0 && !Array.isArray(current)) {
592
+ if (current !== void 0 && current !== null && !Array.isArray(current)) {
592
593
  console.warn(
593
594
  `[formstand] array op on "${path}" but the value at that path is not an array (got ${typeof current}). Operation skipped.`
594
595
  );
@@ -697,6 +698,7 @@ var createForm = (schema, options) => {
697
698
  try {
698
699
  await onValid(result.data);
699
700
  } catch (error) {
701
+ submitOptions?.onError?.(error);
700
702
  return { kind: "error", error };
701
703
  }
702
704
  return { kind: "valid", data: result.data };
@@ -804,6 +806,17 @@ var createForm = (schema, options) => {
804
806
  ...state,
805
807
  ...errorChannels(state, { serverErrors: errors })
806
808
  })),
809
+ addErrors: (errors) => store.setState((state) => ({
810
+ ...state,
811
+ ...errorChannels(state, {
812
+ serverErrors: Object.entries(errors).reduce(
813
+ // Computed keys throughout, so a server field named
814
+ // "__proto__" lands as an own property like everywhere else.
815
+ (acc, [key, list]) => list === void 0 || list.length === 0 ? omitKey(acc, key) : { ...acc, [key]: list },
816
+ state.serverErrors
817
+ )
818
+ })
819
+ })),
807
820
  clearErrors: (path) => store.setState((state) => {
808
821
  const scrub = (map) => path === void 0 ? emptyErrors : omitScope(map, [path]);
809
822
  return {
@@ -943,7 +956,14 @@ var createForm = (schema, options) => {
943
956
  ),
944
957
  handleSubmit: (onValid, onInvalid, opts) => async (event) => {
945
958
  event?.preventDefault();
946
- return submit(onValid, onInvalid, opts);
959
+ const result = await submit(onValid, onInvalid, opts);
960
+ if (result.kind === "error" && opts?.onError === void 0 && process.env.NODE_ENV !== "production") {
961
+ console.warn(
962
+ "[formstand] submit handler threw and nothing observed it \u2014 the form shows no feedback. Pass { onError } to handleSubmit (or setError inside your handler):",
963
+ result.error
964
+ );
965
+ }
966
+ return result;
947
967
  },
948
968
  diff: () => {
949
969
  const state = store.getState();
@@ -1044,13 +1064,16 @@ var persistForm = (form, options) => {
1044
1064
  return void 0;
1045
1065
  }
1046
1066
  };
1047
- const restore = () => {
1067
+ const load = () => {
1048
1068
  try {
1049
1069
  const raw = storage === null ? null : storage.getItem(options.key);
1050
1070
  if (raw === null) return false;
1051
1071
  const parsed = JSON.parse(raw);
1052
1072
  const versioned = options.version !== void 0 && typeof parsed === "object" && parsed !== null && "__v" in parsed;
1053
1073
  if (options.version !== void 0 && !versioned) return false;
1074
+ if (options.version === void 0 && typeof parsed === "object" && parsed !== null && Object.hasOwn(parsed, "__v") && Object.hasOwn(parsed, "values")) {
1075
+ return false;
1076
+ }
1054
1077
  const wrapper = parsed;
1055
1078
  if (versioned && wrapper.__v !== options.version) return false;
1056
1079
  if (versioned && wrapper.values === void 0) return false;
@@ -1086,7 +1109,7 @@ var persistForm = (form, options) => {
1086
1109
  }
1087
1110
  };
1088
1111
  if (apply !== "manual") {
1089
- restore();
1112
+ load();
1090
1113
  }
1091
1114
  const unsubscribe = form.watchValues((values) => {
1092
1115
  cancelPending();
@@ -1100,7 +1123,7 @@ var persistForm = (form, options) => {
1100
1123
  }
1101
1124
  });
1102
1125
  return Object.freeze({
1103
- restore,
1126
+ load,
1104
1127
  clear: () => {
1105
1128
  cancelPending();
1106
1129
  try {
@@ -1114,6 +1137,33 @@ var persistForm = (form, options) => {
1114
1137
  }
1115
1138
  });
1116
1139
  };
1140
+ var usePersistForm = (form, options) => {
1141
+ const inner = useRef(null);
1142
+ const latestOptions = useRef(options);
1143
+ useEffect(() => {
1144
+ latestOptions.current = options;
1145
+ });
1146
+ const key = options.key;
1147
+ useEffect(() => {
1148
+ const handle = persistForm(form, latestOptions.current);
1149
+ inner.current = handle;
1150
+ return () => {
1151
+ inner.current = null;
1152
+ handle.dispose();
1153
+ };
1154
+ }, [form, key]);
1155
+ return useMemo(
1156
+ () => ({
1157
+ load: () => inner.current?.load() ?? false,
1158
+ clear: () => inner.current?.clear(),
1159
+ // dispose stops THIS component's watching early; the effect cleanup
1160
+ // still runs its own dispose harmlessly (persistForm's dispose is
1161
+ // idempotent by construction — it only cancels and unsubscribes).
1162
+ dispose: () => inner.current?.dispose()
1163
+ }),
1164
+ []
1165
+ );
1166
+ };
1117
1167
  var useForm = (schema, options) => {
1118
1168
  const formRef = useRef(null);
1119
1169
  if (formRef.current === null) {
@@ -1168,12 +1218,17 @@ function useField(form, pathArg, options) {
1168
1218
  const path = slice.path;
1169
1219
  const formSchema = form.schema;
1170
1220
  const initialValue = slice.initialValue;
1171
- const emptyValue = useMemo(() => {
1221
+ const { emptyValue, clearable } = useMemo(() => {
1172
1222
  if (formSchema !== void 0) {
1173
1223
  const sub = fieldSchemaAtPath(formSchema, path);
1174
- if (sub !== null) return emptyValueForSchema(sub);
1224
+ if (sub !== null) {
1225
+ return {
1226
+ emptyValue: emptyValueForSchema(sub),
1227
+ clearable: isClearableSchema(sub)
1228
+ };
1229
+ }
1175
1230
  }
1176
- return initialValue === null ? null : void 0;
1231
+ return initialValue === null ? { emptyValue: null, clearable: true } : { emptyValue: void 0, clearable: false };
1177
1232
  }, [formSchema, path, initialValue]);
1178
1233
  const debounceMs = options?.debounceMs;
1179
1234
  const debounceTimerRef = useRef(null);
@@ -1190,9 +1245,13 @@ function useField(form, pathArg, options) {
1190
1245
  if (debounceTimerRef.current !== null) {
1191
1246
  clearTimeout(debounceTimerRef.current);
1192
1247
  }
1248
+ const scheduled = getAtPath(form.store.getState().values, path);
1193
1249
  debounceTimerRef.current = setTimeout(() => {
1194
- void form.validateFieldAsync(path);
1195
1250
  debounceTimerRef.current = null;
1251
+ const state = form.store.getState();
1252
+ if (state.values === state.initialValues) return;
1253
+ if (!Object.is(getAtPath(state.values, path), scheduled)) return;
1254
+ void form.validateFieldAsync(path);
1196
1255
  }, debounceMs);
1197
1256
  return;
1198
1257
  }
@@ -1255,6 +1314,7 @@ function useField(form, pathArg, options) {
1255
1314
  value: slice.value,
1256
1315
  initialValue: slice.initialValue,
1257
1316
  emptyValue,
1317
+ clearable,
1258
1318
  error: slice.error,
1259
1319
  // `?.[0]` covers both the undefined channel and an empty array.
1260
1320
  firstError: slice.error?.[0],
@@ -1269,7 +1329,7 @@ function useField(form, pathArg, options) {
1269
1329
  validateAsync: validateAsync2,
1270
1330
  onBlur
1271
1331
  }),
1272
- [slice, emptyValue, setValue, setTouched, setError, clearError, validate, validateAsync2, onBlur]
1332
+ [slice, emptyValue, clearable, setValue, setTouched, setError, clearError, validate, validateAsync2, onBlur]
1273
1333
  );
1274
1334
  }
1275
1335
 
@@ -1453,25 +1513,64 @@ function useFieldArray(form, pathArg) {
1453
1513
  })),
1454
1514
  [items, ids]
1455
1515
  );
1516
+ const setError = useCallback(
1517
+ // Same string-shorthand normalization as useField.setError, so custom
1518
+ // implementations typed for readonly string[] never receive a bare
1519
+ // string.
1520
+ (errors) => form.setError?.(path, typeof errors === "string" ? [errors] : errors),
1521
+ [form, path]
1522
+ );
1523
+ const clearError = useCallback(() => form.clearErrors?.(path), [form, path]);
1456
1524
  return useMemo(
1457
1525
  () => ({
1526
+ path,
1458
1527
  fields,
1459
1528
  items,
1460
1529
  length: items.length,
1461
1530
  error,
1531
+ firstError: error?.[0],
1532
+ setError,
1533
+ clearError,
1462
1534
  push,
1463
1535
  remove,
1464
1536
  insert,
1465
1537
  move,
1466
1538
  swap
1467
1539
  }),
1468
- [fields, items, error, push, remove, insert, move, swap]
1540
+ [path, fields, items, error, setError, clearError, push, remove, insert, move, swap]
1469
1541
  );
1470
1542
  }
1471
1543
  var useFormError = (form) => useStore(
1472
1544
  form.store,
1473
1545
  useShallow((state) => state.errors[""])
1474
1546
  );
1547
+ function useFormValues(form) {
1548
+ return useStore(form.store, (state) => state.values);
1549
+ }
1550
+
1551
+ // src/react/useFields.ts
1552
+ function useFields(form, paths) {
1553
+ const fields = paths.map(
1554
+ (path) => (
1555
+ // 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
1556
+ useField(form, path)
1557
+ )
1558
+ );
1559
+ const merged = [...new Set(fields.flatMap((field) => field.error ?? []))];
1560
+ return {
1561
+ fields,
1562
+ error: merged.length > 0 ? merged : void 0,
1563
+ firstError: merged[0],
1564
+ touched: fields.some((field) => field.touched),
1565
+ dirty: fields.some((field) => field.dirty),
1566
+ isValidating: fields.some((field) => field.isValidating),
1567
+ onBlur: () => {
1568
+ fields.forEach((field) => {
1569
+ field.onBlur();
1570
+ });
1571
+ }
1572
+ };
1573
+ }
1475
1574
  function useIsDirty(form, path) {
1476
1575
  return useStore(
1477
1576
  form.store,
@@ -1492,6 +1591,19 @@ function useIsValid(form, path) {
1492
1591
  return true;
1493
1592
  });
1494
1593
  }
1594
+ function useIsValidating(form, path) {
1595
+ return useStore(form.store, (state) => {
1596
+ if (path === void 0 || path === "") {
1597
+ if (state.isValidatingForm) return true;
1598
+ }
1599
+ for (const k in state.isValidating) {
1600
+ if (state.isValidating[k] === true && (path === void 0 || path === "" || isPathOrChild(k, path))) {
1601
+ return true;
1602
+ }
1603
+ }
1604
+ return false;
1605
+ });
1606
+ }
1495
1607
  var useIsSubmitting = (form) => useStore(form.store, (state) => state.isSubmitting);
1496
1608
  var useSubmitCount = (form) => useStore(form.store, (state) => state.submitCount);
1497
1609
 
@@ -1511,7 +1623,13 @@ var createFormHooks = (form, name) => {
1511
1623
  const useBoundError = () => useFormError(structural);
1512
1624
  const useBoundIsDirty = (path) => useIsDirty(structural, path);
1513
1625
  const useBoundIsValid = (path) => useIsValid(structural, path);
1626
+ const useBoundIsValidating = (path) => useIsValidating(structural, path);
1514
1627
  const useBoundIsSubmitting = () => useIsSubmitting(structural);
1628
+ const useBoundValues = () => useFormValues(structural);
1629
+ const useBoundFields = (paths) => useFields(
1630
+ structural,
1631
+ paths
1632
+ );
1515
1633
  const useBoundSubmitCount = () => useSubmitCount(structural);
1516
1634
  const prefix = capitalize(name ?? "");
1517
1635
  return {
@@ -1523,37 +1641,13 @@ var createFormHooks = (form, name) => {
1523
1641
  [`use${prefix}Error`]: useBoundError,
1524
1642
  [`use${prefix}IsDirty`]: useBoundIsDirty,
1525
1643
  [`use${prefix}IsValid`]: useBoundIsValid,
1644
+ [`use${prefix}IsValidating`]: useBoundIsValidating,
1526
1645
  [`use${prefix}IsSubmitting`]: useBoundIsSubmitting,
1527
- [`use${prefix}SubmitCount`]: useBoundSubmitCount
1646
+ [`use${prefix}SubmitCount`]: useBoundSubmitCount,
1647
+ [`use${prefix}Values`]: useBoundValues,
1648
+ [`use${prefix}Fields`]: useBoundFields
1528
1649
  };
1529
1650
  };
1530
- function useFormValues(form) {
1531
- return useStore(form.store, (state) => state.values);
1532
- }
1533
-
1534
- // src/react/useFields.ts
1535
- function useFields(form, paths) {
1536
- const fields = paths.map(
1537
- (path) => (
1538
- // 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
1539
- useField(form, path)
1540
- )
1541
- );
1542
- const merged = [...new Set(fields.flatMap((field) => field.error ?? []))];
1543
- return {
1544
- fields,
1545
- error: merged.length > 0 ? merged : void 0,
1546
- firstError: merged[0],
1547
- touched: fields.some((field) => field.touched),
1548
- dirty: fields.some((field) => field.dirty),
1549
- isValidating: fields.some((field) => field.isValidating),
1550
- onBlur: () => {
1551
- fields.forEach((field) => {
1552
- field.onBlur();
1553
- });
1554
- }
1555
- };
1556
- }
1557
1651
 
1558
1652
  // src/react/inputProps.ts
1559
1653
  var hasFieldError = (error) => error !== void 0 && error.length > 0;
@@ -1570,9 +1664,7 @@ var textInputProps = (field) => ({
1570
1664
  "aria-invalid": ariaInvalid(field),
1571
1665
  onChange: (e) => {
1572
1666
  const text = e.target.value;
1573
- field.setValue(
1574
- text === "" && field.emptyValue === null ? null : text
1575
- );
1667
+ field.setValue(text === "" && field.clearable ? field.emptyValue : text);
1576
1668
  },
1577
1669
  onBlur: field.onBlur
1578
1670
  });
@@ -1583,6 +1675,7 @@ var numberInputProps = (field) => ({
1583
1675
  "aria-invalid": ariaInvalid(field),
1584
1676
  onChange: (e) => {
1585
1677
  const parsed = parseNumberText(e.target.value);
1678
+ if (parsed.kind === "invalid") return;
1586
1679
  field.setValue(
1587
1680
  parsed.kind === "number" ? parsed.value : field.emptyValue
1588
1681
  );
@@ -1645,9 +1738,7 @@ var selectProps = (field) => ({
1645
1738
  "aria-invalid": ariaInvalid(field),
1646
1739
  onChange: (e) => {
1647
1740
  const v = e.target.value;
1648
- field.setValue(
1649
- v === "" && field.emptyValue === null ? null : v
1650
- );
1741
+ field.setValue(v === "" && field.clearable ? field.emptyValue : v);
1651
1742
  },
1652
1743
  onBlur: field.onBlur
1653
1744
  });
@@ -1854,7 +1945,7 @@ var SelectField = ({
1854
1945
  const field = useField(form, path);
1855
1946
  const a11y = useFieldA11y(field.error);
1856
1947
  const select = selectProps(field);
1857
- const clearable = field.emptyValue === null;
1948
+ const clearable = field.clearable;
1858
1949
  const hasExplicitEmptyOption = options.some((opt) => opt.value === "");
1859
1950
  const showEmptyOption = !hasExplicitEmptyOption && (clearable || select.value === "" || placeholder !== void 0);
1860
1951
  return /* @__PURE__ */ jsx(
@@ -1943,6 +2034,6 @@ var focusField = (path, root) => {
1943
2034
  return path === "" ? (root !== void 0 || document.querySelectorAll("form").length <= 1) && focusFirstOf(namedControls(scope).filter(isFocusCandidate)) : focusFirstOf(pathCandidates(scope, [path]).filter(isFocusCandidate));
1944
2035
  };
1945
2036
 
1946
- export { CheckboxField, DateField, NumberField, SelectField, TextField, checkboxProps, createForm, createFormContext, createFormHooks, dateInputProps, dateToInputText, emptyValueForSchema, flattenIssues, focusField, focusFirstError, hasFieldError, isAsyncRequiredError, numberInputProps, numberToInputText, parseDateText, parseNumberText, persistForm, selectProps, shouldValidateOn, textInputProps, useField, useFieldArray, useFields, useForm, useFormError, useFormValues, useIsDirty, useIsSubmitting, useIsValid, useNumberInput, useSubmitCount, useVariantField, validateAsync, validateSync };
2037
+ export { CheckboxField, DateField, NumberField, SelectField, TextField, checkboxProps, createForm, createFormContext, createFormHooks, dateInputProps, dateToInputText, emptyValueForSchema, flattenIssues, focusField, focusFirstError, hasFieldError, isAsyncRequiredError, isClearableSchema, numberInputProps, numberToInputText, parseDateText, parseNumberText, persistForm, selectProps, shouldValidateOn, textInputProps, useField, useFieldArray, useFields, useForm, useFormError, useFormValues, useIsDirty, useIsSubmitting, useIsValid, useIsValidating, useNumberInput, usePersistForm, useSubmitCount, useVariantField, validateAsync, validateSync };
1947
2038
  //# sourceMappingURL=index.js.map
1948
2039
  //# sourceMappingURL=index.js.map