formstand 0.6.0 → 0.8.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
@@ -1,6 +1,6 @@
1
1
  import { createStore } from 'zustand/vanilla';
2
2
  import { z } from 'zod';
3
- import { useRef, useEffect, createContext, useMemo, useCallback, useId, useState, useContext } from 'react';
3
+ import { useRef, useEffect, createContext, useMemo, useCallback, useState, useId, useContext } from 'react';
4
4
  import { jsxs, jsx } from 'react/jsx-runtime';
5
5
  import { useStore } from 'zustand/react';
6
6
  import { useShallow } from 'zustand/react/shallow';
@@ -57,7 +57,7 @@ var valuesEqual = (a, b) => {
57
57
  if (isPlainObject(a) && isPlainObject(b)) {
58
58
  const aKeys = Object.keys(a);
59
59
  const bKeys = Object.keys(b);
60
- return aKeys.length === bKeys.length && aKeys.every((k) => valuesEqual(a[k], b[k]));
60
+ return aKeys.length === bKeys.length && aKeys.every((k) => Object.hasOwn(b, k) && valuesEqual(a[k], b[k]));
61
61
  }
62
62
  return false;
63
63
  };
@@ -92,7 +92,7 @@ var readSegment = (obj, segment) => {
92
92
  if (Array.isArray(obj)) {
93
93
  return typeof segment === "number" ? obj[segment] : void 0;
94
94
  }
95
- return isPlainRecord(obj) ? obj[String(segment)] : void 0;
95
+ return isPlainRecord(obj) && Object.hasOwn(obj, String(segment)) ? obj[String(segment)] : void 0;
96
96
  };
97
97
  var getAtPath = (obj, path) => parsePath(path).reduce(readSegment, obj);
98
98
  var slotAtPath = (obj, path) => {
@@ -135,6 +135,12 @@ var writeSegments = (obj, segments, value) => {
135
135
  return writeIntoArray(obj, head, rest, value);
136
136
  }
137
137
  if (isPlainRecord(obj)) {
138
+ if (!isPlainObject(obj)) {
139
+ console.warn(
140
+ `[formstand] cannot write key "${String(head)}" through a non-plain object (Date/Map/Set/class instance); value left unchanged.`
141
+ );
142
+ return obj;
143
+ }
138
144
  const key = String(head);
139
145
  return { ...obj, [key]: writeSegments(obj[key], rest, value) };
140
146
  }
@@ -668,7 +674,11 @@ var createForm = (schema, options) => {
668
674
  });
669
675
  }
670
676
  const merged = stale ? result.errors : store.getState().errors;
671
- onInvalid?.(merged);
677
+ try {
678
+ onInvalid?.(merged);
679
+ } catch (error) {
680
+ return { kind: "error", error };
681
+ }
672
682
  return { kind: "invalid", errors: merged };
673
683
  }
674
684
  if (!stale) {
@@ -928,9 +938,13 @@ var createForm = (schema, options) => {
928
938
  // In-flight state is owned by live passes, never by snapshots: a
929
939
  // snapshotted flag has no pass left to clear it (the pass that set
930
940
  // it cleared its ORIGINAL entry when it settled), so restoring one
931
- // would stick it forever.
941
+ // would stick it forever. isSubmitting follows the same rule — a
942
+ // mid-submit snapshot restored later must not disable submit
943
+ // buttons forever, so it reflects the LIVE submit count instead of
944
+ // the snapshotted flag (a live pass's finally still resets it).
932
945
  isValidating: emptyBools,
933
- isValidatingForm: false
946
+ isValidatingForm: false,
947
+ isSubmitting: inFlight.count > 0
934
948
  };
935
949
  })
936
950
  });
@@ -958,15 +972,17 @@ var useForm = (schema, options) => {
958
972
  formRef.current = createForm(schema, options);
959
973
  }
960
974
  const form = formRef.current;
975
+ const mountInitialsRef = useRef(options.initialValues);
961
976
  const warnedRef = useRef(false);
962
977
  useEffect(() => {
963
- if (!warnedRef.current && form.schema !== schema) {
978
+ const changedInitials = !Object.is(options.initialValues, mountInitialsRef.current) && !valuesEqual(options.initialValues, mountInitialsRef.current);
979
+ if (!warnedRef.current && (form.schema !== schema || changedInitials)) {
964
980
  warnedRef.current = true;
965
981
  console.warn(
966
- "[formstand] useForm received a different schema reference after the first render; schema and options changes are ignored for the life of the component. If the schema is defined inline this is harmless (but consider hoisting it to module scope). For initial values that arrive later (e.g. from a fetch), call form.adoptValues(values) or form.reset(values) instead."
982
+ "[formstand] useForm received a different schema or initial values after the first render; schema and options changes are ignored for the life of the component. If the schema is defined inline this is harmless (but consider hoisting it to module scope). For initial values that arrive later (e.g. from a fetch), call form.adoptValues(values) or form.reset(values) instead."
967
983
  );
968
984
  }
969
- }, [form, schema]);
985
+ }, [form, schema, options.initialValues]);
970
986
  return form;
971
987
  };
972
988
  var createFormContext = () => {
@@ -983,38 +999,6 @@ var createFormContext = () => {
983
999
  };
984
1000
  return { Provider, useFormContext };
985
1001
  };
986
- function useFormSelector(form, selector) {
987
- return useStore(form.store, selector);
988
- }
989
- function useFormSelectorShallow(form, selector) {
990
- return useStore(form.store, useShallow(selector));
991
- }
992
- var useFormError = (form) => useStore(
993
- form.store,
994
- useShallow((state) => state.errors[""])
995
- );
996
- function useIsDirty(form, path) {
997
- return useStore(
998
- form.store,
999
- (state) => path === void 0 || path === "" ? isFieldDirty(state.values, state.initialValues) : isFieldDirty(
1000
- getAtPath(state.values, path),
1001
- getAtPath(state.initialValues, path)
1002
- )
1003
- );
1004
- }
1005
- function useIsValid(form, path) {
1006
- return useStore(form.store, (state) => {
1007
- for (const k in state.errors) {
1008
- const errs = state.errors[k];
1009
- if (errs !== void 0 && errs.length > 0 && (path === void 0 || path === "" || isPathOrChild(k, path))) {
1010
- return false;
1011
- }
1012
- }
1013
- return true;
1014
- });
1015
- }
1016
- var useIsSubmitting = (form) => useStore(form.store, (state) => state.isSubmitting);
1017
- var useSubmitCount = (form) => useStore(form.store, (state) => state.submitCount);
1018
1002
  function useField(form, pathArg, options) {
1019
1003
  const slice = useStore(
1020
1004
  form.store,
@@ -1144,6 +1128,168 @@ function useField(form, pathArg, options) {
1144
1128
  [slice, emptyValue, setValue, setTouched, setError, clearError, validate, validateAsync2, onBlur]
1145
1129
  );
1146
1130
  }
1131
+ var EMPTY_ID_STATE = { items: [], ids: [], counter: 0 };
1132
+ var EMPTY_ITEMS = [];
1133
+ var reconcileIds = (prev, nextItems) => {
1134
+ if (prev.items === nextItems) return prev;
1135
+ if (prev.items.length === nextItems.length && prev.items.every((item, i) => Object.is(item, nextItems[i]))) {
1136
+ return prev;
1137
+ }
1138
+ const buckets = prev.items.reduce((acc, item, i) => {
1139
+ const bucket = acc.get(item);
1140
+ if (bucket === void 0) {
1141
+ acc.set(item, [i]);
1142
+ } else {
1143
+ bucket.push(i);
1144
+ }
1145
+ return acc;
1146
+ }, /* @__PURE__ */ new Map());
1147
+ const matchedIds = nextItems.map((item) => {
1148
+ const prevIndex = buckets.get(item)?.shift();
1149
+ return prevIndex === void 0 ? null : prev.ids[prevIndex] ?? null;
1150
+ });
1151
+ const leftoverAt = new Map(
1152
+ [...buckets.values()].flat().flatMap((i) => {
1153
+ const id = prev.ids[i];
1154
+ return id === void 0 ? [] : [[i, id]];
1155
+ })
1156
+ );
1157
+ const reused = matchedIds.map((id, i) => id ?? leftoverAt.get(i) ?? null);
1158
+ const mintCounts = reused.reduce((acc, id) => {
1159
+ const count = acc[acc.length - 1] ?? 0;
1160
+ return [...acc, id === null ? count + 1 : count];
1161
+ }, []);
1162
+ const minted = mintCounts[mintCounts.length - 1] ?? 0;
1163
+ const ids = reused.map(
1164
+ (id, i) => id ?? `__zfa_${prev.counter + (mintCounts[i] ?? 0)}`
1165
+ );
1166
+ return { items: nextItems, ids, counter: prev.counter + minted };
1167
+ };
1168
+ function useFieldArray(form, pathArg) {
1169
+ const path = useStore(
1170
+ form.store,
1171
+ (state) => typeof pathArg === "function" ? pathArg(state) : pathArg
1172
+ );
1173
+ const slice = useStore(
1174
+ form.store,
1175
+ useShallow((state) => {
1176
+ const value = getAtPath(state.values, path);
1177
+ return {
1178
+ items: Array.isArray(value) ? value : EMPTY_ITEMS,
1179
+ error: state.errors[path]
1180
+ };
1181
+ })
1182
+ );
1183
+ const items = slice.items;
1184
+ const error = slice.error;
1185
+ const [idEntry, setIdEntry] = useState({ form, path, state: EMPTY_ID_STATE });
1186
+ const base = idEntry.form === form && idEntry.path === path ? idEntry.state : { ...EMPTY_ID_STATE, counter: idEntry.state.counter };
1187
+ const nextIdState = reconcileIds(base, items);
1188
+ if (idEntry.form !== form || idEntry.path !== path || idEntry.state !== nextIdState) {
1189
+ setIdEntry({ form, path, state: nextIdState });
1190
+ }
1191
+ const ids = nextIdState.ids;
1192
+ const push = useCallback(
1193
+ (item) => form.arrayPush(path, item),
1194
+ [form, path]
1195
+ );
1196
+ const remove = useCallback(
1197
+ (index) => form.arrayRemove(path, index),
1198
+ [form, path]
1199
+ );
1200
+ const insert = useCallback(
1201
+ (index, item) => form.arrayInsert(path, index, item),
1202
+ [form, path]
1203
+ );
1204
+ const move = useCallback(
1205
+ (from, to) => form.arrayMove(path, from, to),
1206
+ [form, path]
1207
+ );
1208
+ const swap = useCallback(
1209
+ (a, b) => form.arraySwap(path, a, b),
1210
+ [form, path]
1211
+ );
1212
+ const fields = useMemo(
1213
+ () => items.map((value, index) => ({
1214
+ id: ids[index] ?? `__zfa_fallback_${index}`,
1215
+ value
1216
+ })),
1217
+ [items, ids]
1218
+ );
1219
+ return useMemo(
1220
+ () => ({
1221
+ fields,
1222
+ items,
1223
+ length: items.length,
1224
+ error,
1225
+ push,
1226
+ remove,
1227
+ insert,
1228
+ move,
1229
+ swap
1230
+ }),
1231
+ [fields, items, error, push, remove, insert, move, swap]
1232
+ );
1233
+ }
1234
+ var useFormError = (form) => useStore(
1235
+ form.store,
1236
+ useShallow((state) => state.errors[""])
1237
+ );
1238
+ function useIsDirty(form, path) {
1239
+ return useStore(
1240
+ form.store,
1241
+ (state) => path === void 0 || path === "" ? isFieldDirty(state.values, state.initialValues) : isFieldDirty(
1242
+ getAtPath(state.values, path),
1243
+ getAtPath(state.initialValues, path)
1244
+ )
1245
+ );
1246
+ }
1247
+ function useIsValid(form, path) {
1248
+ return useStore(form.store, (state) => {
1249
+ for (const k in state.errors) {
1250
+ const errs = state.errors[k];
1251
+ if (errs !== void 0 && errs.length > 0 && (path === void 0 || path === "" || isPathOrChild(k, path))) {
1252
+ return false;
1253
+ }
1254
+ }
1255
+ return true;
1256
+ });
1257
+ }
1258
+ var useIsSubmitting = (form) => useStore(form.store, (state) => state.isSubmitting);
1259
+ var useSubmitCount = (form) => useStore(form.store, (state) => state.submitCount);
1260
+ function useFormSelector(form, selector) {
1261
+ return useStore(form.store, selector);
1262
+ }
1263
+ function useFormSelectorShallow(form, selector) {
1264
+ return useStore(form.store, useShallow(selector));
1265
+ }
1266
+
1267
+ // src/react/createFormHooks.ts
1268
+ var capitalize = (name) => name.length === 0 ? name : name.charAt(0).toUpperCase() + name.slice(1);
1269
+ var createFormHooks = (form, name) => {
1270
+ const structural = form;
1271
+ const useBoundField = (path, options) => useField(structural, path, options);
1272
+ const useBoundFieldArray = (path) => useFieldArray(structural, path);
1273
+ const useBoundSelector = (selector) => useFormSelector(structural, selector);
1274
+ const useBoundSelectorShallow = (selector) => useFormSelectorShallow(structural, selector);
1275
+ const useBoundError = () => useFormError(structural);
1276
+ const useBoundIsDirty = (path) => useIsDirty(structural, path);
1277
+ const useBoundIsValid = (path) => useIsValid(structural, path);
1278
+ const useBoundIsSubmitting = () => useIsSubmitting(structural);
1279
+ const useBoundSubmitCount = () => useSubmitCount(structural);
1280
+ const prefix = capitalize(name ?? "");
1281
+ return {
1282
+ [`use${prefix}Field`]: useBoundField,
1283
+ [`use${prefix}FieldArray`]: useBoundFieldArray,
1284
+ [`use${prefix}Selector`]: useBoundSelector,
1285
+ [`use${prefix}SelectorShallow`]: useBoundSelectorShallow,
1286
+ [`use${prefix}Error`]: useBoundError,
1287
+ [`use${prefix}IsDirty`]: useBoundIsDirty,
1288
+ [`use${prefix}IsValid`]: useBoundIsValid,
1289
+ [`use${prefix}IsSubmitting`]: useBoundIsSubmitting,
1290
+ [`use${prefix}SubmitCount`]: useBoundSubmitCount
1291
+ };
1292
+ };
1147
1293
 
1148
1294
  // src/react/inputProps.ts
1149
1295
  var ariaInvalid = (field) => field.error !== void 0 && field.error.length > 0 ? true : void 0;
@@ -1334,7 +1480,8 @@ var SelectField = ({
1334
1480
  const id = useId();
1335
1481
  const errorId = `${id}-error`;
1336
1482
  const field = useField(form, path);
1337
- const showEmptyOption = field.value === void 0 || field.value === null || placeholder !== void 0;
1483
+ const clearable = field.emptyValue === null;
1484
+ const showEmptyOption = clearable || field.value === void 0 || field.value === null || placeholder !== void 0;
1338
1485
  return /* @__PURE__ */ jsxs("div", { className: "zf-field", children: [
1339
1486
  label !== void 0 ? /* @__PURE__ */ jsx("label", { htmlFor: id, className: "zf-label", children: label }) : null,
1340
1487
  /* @__PURE__ */ jsxs(
@@ -1345,7 +1492,7 @@ var SelectField = ({
1345
1492
  ...selectProps(field),
1346
1493
  "aria-describedby": hasError(field.error) ? errorId : void 0,
1347
1494
  children: [
1348
- showEmptyOption ? /* @__PURE__ */ jsx("option", { value: "", disabled: true, children: placeholder ?? "" }) : null,
1495
+ showEmptyOption ? /* @__PURE__ */ jsx("option", { value: "", disabled: !clearable, children: placeholder ?? "" }) : null,
1349
1496
  options.map((opt, index) => (
1350
1497
  // Index-qualified: option lists may legitimately repeat a value
1351
1498
  // (e.g. differently-labelled aliases), and bare value keys would
@@ -1398,111 +1545,7 @@ var focusField = (path, root) => {
1398
1545
  const controls = focusableControls(root ?? document);
1399
1546
  return path === "" ? (root !== void 0 || document.querySelectorAll("form").length <= 1) && focusFirstOf(controls) : focusFirstOf(controls.filter((el) => nameMatchesAny(el, [path])));
1400
1547
  };
1401
- var EMPTY_ID_STATE = { items: [], ids: [], counter: 0 };
1402
- var EMPTY_ITEMS = [];
1403
- var reconcileIds = (prev, nextItems) => {
1404
- if (prev.items === nextItems) return prev;
1405
- if (prev.items.length === nextItems.length && prev.items.every((item, i) => Object.is(item, nextItems[i]))) {
1406
- return prev;
1407
- }
1408
- const buckets = prev.items.reduce((acc, item, i) => {
1409
- const bucket = acc.get(item);
1410
- if (bucket === void 0) {
1411
- acc.set(item, [i]);
1412
- } else {
1413
- bucket.push(i);
1414
- }
1415
- return acc;
1416
- }, /* @__PURE__ */ new Map());
1417
- const matchedIds = nextItems.map((item) => {
1418
- const prevIndex = buckets.get(item)?.shift();
1419
- return prevIndex === void 0 ? null : prev.ids[prevIndex] ?? null;
1420
- });
1421
- const leftover = [...buckets.values()].flat().sort((a, b) => a - b).map((i) => prev.ids[i]).filter((id) => id !== void 0);
1422
- const ordinals = matchedIds.reduce((acc, id) => {
1423
- const count = acc.length === 0 ? 0 : acc[acc.length - 1] ?? 0;
1424
- acc.push(id === null ? count + 1 : count);
1425
- return acc;
1426
- }, []);
1427
- const unmatched = ordinals.length === 0 ? 0 : ordinals[ordinals.length - 1] ?? 0;
1428
- const ids = matchedIds.map((id, i) => {
1429
- if (id !== null) return id;
1430
- const ordinal = ordinals[i] ?? 0;
1431
- return leftover[ordinal - 1] ?? `__zfa_${prev.counter + (ordinal - leftover.length)}`;
1432
- });
1433
- return {
1434
- items: nextItems,
1435
- ids,
1436
- counter: prev.counter + Math.max(0, unmatched - leftover.length)
1437
- };
1438
- };
1439
- function useFieldArray(form, pathArg) {
1440
- const path = useStore(
1441
- form.store,
1442
- (state) => typeof pathArg === "function" ? pathArg(state) : pathArg
1443
- );
1444
- const slice = useStore(
1445
- form.store,
1446
- useShallow((state) => {
1447
- const value = getAtPath(state.values, path);
1448
- return {
1449
- items: Array.isArray(value) ? value : EMPTY_ITEMS,
1450
- error: state.errors[path]
1451
- };
1452
- })
1453
- );
1454
- const items = slice.items;
1455
- const error = slice.error;
1456
- const [idEntry, setIdEntry] = useState({ form, path, state: EMPTY_ID_STATE });
1457
- const base = idEntry.form === form && idEntry.path === path ? idEntry.state : { ...EMPTY_ID_STATE, counter: idEntry.state.counter };
1458
- const nextIdState = reconcileIds(base, items);
1459
- if (idEntry.form !== form || idEntry.path !== path || idEntry.state !== nextIdState) {
1460
- setIdEntry({ form, path, state: nextIdState });
1461
- }
1462
- const ids = nextIdState.ids;
1463
- const push = useCallback(
1464
- (item) => form.arrayPush(path, item),
1465
- [form, path]
1466
- );
1467
- const remove = useCallback(
1468
- (index) => form.arrayRemove(path, index),
1469
- [form, path]
1470
- );
1471
- const insert = useCallback(
1472
- (index, item) => form.arrayInsert(path, index, item),
1473
- [form, path]
1474
- );
1475
- const move = useCallback(
1476
- (from, to) => form.arrayMove(path, from, to),
1477
- [form, path]
1478
- );
1479
- const swap = useCallback(
1480
- (a, b) => form.arraySwap(path, a, b),
1481
- [form, path]
1482
- );
1483
- const fields = useMemo(
1484
- () => items.map((value, index) => ({
1485
- id: ids[index] ?? `__zfa_fallback_${index}`,
1486
- value
1487
- })),
1488
- [items, ids]
1489
- );
1490
- return useMemo(
1491
- () => ({
1492
- fields,
1493
- items,
1494
- length: items.length,
1495
- error,
1496
- push,
1497
- remove,
1498
- insert,
1499
- move,
1500
- swap
1501
- }),
1502
- [fields, items, error, push, remove, insert, move, swap]
1503
- );
1504
- }
1505
1548
 
1506
- export { CheckboxField, NumberField, SelectField, TextField, checkboxProps, createForm, createFormContext, emptyValueForSchema, flattenIssues, focusField, focusFirstError, getAtPath, isAsyncRequiredError, numberInputProps, numberToInputText, parseNumberText, parsePath, selectProps, setAtPath, shouldValidateOn, textInputProps, useField, useFieldArray, useForm, useFormError, useFormSelector, useFormSelectorShallow, useIsDirty, useIsSubmitting, useIsValid, useSubmitCount, validateAsync, validateSync };
1549
+ export { CheckboxField, NumberField, SelectField, TextField, checkboxProps, createForm, createFormContext, createFormHooks, emptyValueForSchema, flattenIssues, focusField, focusFirstError, getAtPath, isAsyncRequiredError, numberInputProps, numberToInputText, parseNumberText, parsePath, selectProps, setAtPath, shouldValidateOn, textInputProps, useField, useFieldArray, useForm, useFormError, useFormSelector, useFormSelectorShallow, useIsDirty, useIsSubmitting, useIsValid, useSubmitCount, validateAsync, validateSync };
1507
1550
  //# sourceMappingURL=index.js.map
1508
1551
  //# sourceMappingURL=index.js.map