formstand 0.19.0 → 0.21.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,9 +1,11 @@
1
- import { getAtPath, slotAtPath, setAtPath, isPlainObject, hasOwnSlot, valuesEqual, isFieldDirty, useFormSelector, useFormSelectorShallow, parsePath } from './chunk-N7WPXYCU.js';
2
- export { getAtPath, parsePath, setAtPath, useFormSelector, useFormSelectorShallow } from './chunk-N7WPXYCU.js';
1
+ import { getAtPath, slotAtPath, setAtPath, isPlainObject, hasOwnSlot, valuesEqual, useFormSelectorShallow, isFieldDirty, useFormSelector, parsePath } from './chunk-OB23TUKY.js';
2
+ export { getAtPath, parsePath, setAtPath, useFormSelector, useFormSelectorShallow } from './chunk-OB23TUKY.js';
3
+ import { shouldValidateOn } from './chunk-MQ6262S3.js';
4
+ export { shouldValidateOn } from './chunk-MQ6262S3.js';
3
5
  import { createStore } from 'zustand/vanilla';
4
6
  import { devtools } from 'zustand/middleware';
5
7
  import { z } from 'zod';
6
- import { useRef, useEffect, useMemo, useCallback, useActionState, createContext, useState, useContext, useId } from 'react';
8
+ import { useRef, useEffect, useMemo, useState, useCallback, useActionState, createContext, useContext, useId } from 'react';
7
9
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
8
10
  import { useStore } from 'zustand/react';
9
11
  import { useShallow } from 'zustand/react/shallow';
@@ -828,27 +830,25 @@ var createForm = (schema, options) => {
828
830
  };
829
831
  }),
830
832
  updateState: (updater) => {
831
- const valuesBefore = store.getState().values;
832
- store.setState((state) => {
833
- const patch = updater(state);
834
- if (Object.keys(patch).length === 0) return state;
835
- if (patch.errors !== void 0 && patch.errors !== state.errors) {
836
- console.warn(
837
- "[formstand] `errors` is derived from schemaErrors/serverErrors \u2014 patch those channels instead; the direct `errors` patch is ignored."
838
- );
839
- }
840
- const next = { ...state, ...patch };
841
- return {
842
- ...next,
843
- ...errorChannels(state, {
844
- schemaErrors: next.schemaErrors,
845
- serverErrors: next.serverErrors
846
- })
847
- };
848
- });
849
- if (store.getState().values !== valuesBefore) {
833
+ const state = store.getState();
834
+ const patch = updater(state);
835
+ if (Object.keys(patch).length === 0) return;
836
+ if (patch.errors !== void 0 && patch.errors !== state.errors) {
837
+ console.warn(
838
+ "[formstand] `errors` is derived from schemaErrors/serverErrors \u2014 patch those channels instead; the direct `errors` patch is ignored."
839
+ );
840
+ }
841
+ const next = { ...state, ...patch };
842
+ if (next.values !== state.values) {
850
843
  clearArrayOpsUnder(store, "");
851
844
  }
845
+ store.setState(() => ({
846
+ ...next,
847
+ ...errorChannels(state, {
848
+ schemaErrors: next.schemaErrors,
849
+ serverErrors: next.serverErrors
850
+ })
851
+ }));
852
852
  },
853
853
  adoptValues: (values) => {
854
854
  sequences.clear();
@@ -1008,21 +1008,33 @@ var createForm = (schema, options) => {
1008
1008
  });
1009
1009
  };
1010
1010
 
1011
- // src/core/mode.ts
1012
- var shouldValidateOn = (trigger, mode, reValidateMode, submitAttempted, touched = false) => {
1013
- const effective = submitAttempted ? reValidateMode : mode;
1014
- switch (effective) {
1015
- case "onChange":
1016
- return trigger === "change";
1017
- case "onBlur":
1018
- return trigger === "blur";
1019
- case "onSubmit":
1020
- return false;
1021
- case "onTouched":
1022
- return trigger === "blur" || touched;
1023
- case "all":
1024
- return true;
1025
- }
1011
+ // src/core/debounce.ts
1012
+ var createDebouncer = (ms) => {
1013
+ const timer = {
1014
+ current: null
1015
+ };
1016
+ const cancel = () => {
1017
+ if (timer.current !== null) {
1018
+ clearTimeout(timer.current);
1019
+ timer.current = null;
1020
+ }
1021
+ };
1022
+ const schedule = (run) => {
1023
+ cancel();
1024
+ if (ms === 0) {
1025
+ run();
1026
+ return;
1027
+ }
1028
+ timer.current = setTimeout(() => {
1029
+ timer.current = null;
1030
+ run();
1031
+ }, ms);
1032
+ };
1033
+ return Object.freeze({
1034
+ schedule,
1035
+ cancel,
1036
+ pending: () => timer.current !== null
1037
+ });
1026
1038
  };
1027
1039
 
1028
1040
  // src/core/persist.ts
@@ -1099,28 +1111,13 @@ var persistForm = (form, options) => {
1099
1111
  } catch {
1100
1112
  }
1101
1113
  };
1102
- const timerRef = {
1103
- current: null
1104
- };
1105
- const cancelPending = () => {
1106
- if (timerRef.current !== null) {
1107
- clearTimeout(timerRef.current);
1108
- timerRef.current = null;
1109
- }
1110
- };
1114
+ const writes = createDebouncer(debounceMs);
1115
+ const cancelPending = () => writes.cancel();
1111
1116
  if (apply !== "manual") {
1112
1117
  load();
1113
1118
  }
1114
1119
  const unsubscribe = form.watchValues((values) => {
1115
- cancelPending();
1116
- if (debounceMs === 0) {
1117
- writeDraft(values);
1118
- } else {
1119
- timerRef.current = setTimeout(() => {
1120
- timerRef.current = null;
1121
- writeDraft(values);
1122
- }, debounceMs);
1123
- }
1120
+ writes.schedule(() => writeDraft(values));
1124
1121
  });
1125
1122
  return Object.freeze({
1126
1123
  load,
@@ -1183,6 +1180,233 @@ var useForm = (schema, options) => {
1183
1180
  }, [form, schema, options.initialValues]);
1184
1181
  return form;
1185
1182
  };
1183
+
1184
+ // src/react/inputProps.ts
1185
+ var hasFieldError = (error) => error !== void 0 && error.length > 0;
1186
+ var ariaInvalid = (field) => hasFieldError(field.error) ? true : void 0;
1187
+ var numberToInputText = (value) => value === void 0 || value === null || Number.isNaN(value) ? "" : String(value);
1188
+ var parseNumberText = (text) => {
1189
+ if (text.trim() === "") return { kind: "empty" };
1190
+ const parsed = Number(text);
1191
+ return Number.isFinite(parsed) ? { kind: "number", value: parsed } : { kind: "invalid" };
1192
+ };
1193
+ var textInputProps = (field) => ({
1194
+ name: field.path,
1195
+ value: field.value ?? "",
1196
+ "aria-invalid": ariaInvalid(field),
1197
+ onChange: (e) => {
1198
+ const text = e.target.value;
1199
+ field.setValue(text === "" && field.clearable ? field.emptyValue : text);
1200
+ },
1201
+ onBlur: field.onBlur
1202
+ });
1203
+ var numberInputProps = (field) => ({
1204
+ type: "number",
1205
+ name: field.path,
1206
+ value: numberToInputText(field.value),
1207
+ "aria-invalid": ariaInvalid(field),
1208
+ onChange: (e) => {
1209
+ const parsed = parseNumberText(e.target.value);
1210
+ if (parsed.kind === "invalid") return;
1211
+ field.setValue(
1212
+ parsed.kind === "number" ? parsed.value : field.emptyValue
1213
+ );
1214
+ },
1215
+ onBlur: field.onBlur
1216
+ });
1217
+ var dateToInputText = (value) => {
1218
+ if (value === void 0 || value === null || Number.isNaN(value.getTime())) {
1219
+ return "";
1220
+ }
1221
+ const pad = (part) => String(part).padStart(2, "0");
1222
+ return `${String(value.getFullYear()).padStart(4, "0")}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
1223
+ };
1224
+ var parseDateText = (text) => {
1225
+ if (text.trim() === "") return { kind: "empty" };
1226
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
1227
+ if (match === null) return { kind: "invalid" };
1228
+ const [, year, month, day] = match;
1229
+ const y = Number(year);
1230
+ const date = new Date(y, Number(month) - 1, Number(day));
1231
+ if (y < 100) date.setFullYear(y);
1232
+ return date.getFullYear() === y && date.getMonth() === Number(month) - 1 && date.getDate() === Number(day) ? { kind: "date", value: date } : { kind: "invalid" };
1233
+ };
1234
+ var dateInputProps = (field) => ({
1235
+ type: "date",
1236
+ name: field.path,
1237
+ value: dateToInputText(field.value),
1238
+ "aria-invalid": ariaInvalid(field),
1239
+ onChange: (e) => {
1240
+ const parsed = parseDateText(e.target.value);
1241
+ if (parsed.kind !== "date") {
1242
+ field.setValue(field.emptyValue);
1243
+ return;
1244
+ }
1245
+ const current = field.value;
1246
+ const next = current instanceof Date && !Number.isNaN(current.getTime()) ? new Date(
1247
+ parsed.value.getFullYear(),
1248
+ parsed.value.getMonth(),
1249
+ parsed.value.getDate(),
1250
+ current.getHours(),
1251
+ current.getMinutes(),
1252
+ current.getSeconds(),
1253
+ current.getMilliseconds()
1254
+ ) : parsed.value;
1255
+ field.setValue(next);
1256
+ },
1257
+ onBlur: field.onBlur
1258
+ });
1259
+ var checkboxProps = (field) => ({
1260
+ type: "checkbox",
1261
+ name: field.path,
1262
+ checked: field.value ?? false,
1263
+ "aria-invalid": ariaInvalid(field),
1264
+ onChange: (e) => field.setValue(e.target.checked),
1265
+ onBlur: field.onBlur
1266
+ });
1267
+ var selectProps = (field) => ({
1268
+ name: field.path,
1269
+ value: field.value ?? "",
1270
+ "aria-invalid": ariaInvalid(field),
1271
+ onChange: (e) => {
1272
+ const v = e.target.value;
1273
+ field.setValue(v === "" && field.clearable ? field.emptyValue : v);
1274
+ },
1275
+ onBlur: field.onBlur
1276
+ });
1277
+
1278
+ // src/react/useMaskedInput.ts
1279
+ var useMaskedInput = (field, options) => {
1280
+ const [edit, setEdit] = useState({
1281
+ raw: null,
1282
+ pushed: void 0
1283
+ });
1284
+ const externallyChanged = edit.raw !== null && !Object.is(field.value, edit.pushed);
1285
+ if (externallyChanged) {
1286
+ setEdit({ raw: null, pushed: void 0 });
1287
+ }
1288
+ const raw = externallyChanged ? null : edit.raw;
1289
+ return {
1290
+ name: field.path,
1291
+ value: raw ?? (field.value === void 0 || field.value === null ? "" : options.format(field.value)),
1292
+ "aria-invalid": hasFieldError(field.error) ? true : void 0,
1293
+ onChange: (e) => {
1294
+ const text = e.target.value;
1295
+ const parsed = options.parse(text);
1296
+ switch (parsed.kind) {
1297
+ case "value":
1298
+ setEdit({ raw: text, pushed: parsed.value });
1299
+ field.setValue(parsed.value);
1300
+ return;
1301
+ case "empty": {
1302
+ const empty = field.emptyValue;
1303
+ setEdit({ raw: text, pushed: empty });
1304
+ field.setValue(empty);
1305
+ return;
1306
+ }
1307
+ case "invalid":
1308
+ setEdit({ raw: text, pushed: field.value });
1309
+ return;
1310
+ }
1311
+ },
1312
+ onBlur: () => {
1313
+ setEdit({ raw: null, pushed: void 0 });
1314
+ field.onBlur();
1315
+ }
1316
+ };
1317
+ };
1318
+ var useFormSteps = (form, steps) => {
1319
+ const [step, setStep] = useState(0);
1320
+ const [visited, setVisited] = useState(
1321
+ () => /* @__PURE__ */ new Set([0])
1322
+ );
1323
+ const latest = useRef(steps);
1324
+ useEffect(() => {
1325
+ latest.current = steps;
1326
+ });
1327
+ const validateStep = useCallback(
1328
+ async (index) => {
1329
+ const definition = latest.current[index];
1330
+ if (definition === void 0) return true;
1331
+ const result = await form.validateFieldsAsync(definition.fields);
1332
+ if (result.kind === "invalid") {
1333
+ Object.keys(result.errors).forEach((key) => {
1334
+ form.setTouched(key, true);
1335
+ });
1336
+ return false;
1337
+ }
1338
+ return true;
1339
+ },
1340
+ [form]
1341
+ );
1342
+ const next = useCallback(async () => {
1343
+ const ok = await validateStep(step);
1344
+ if (!ok) return false;
1345
+ const target = Math.min(step + 1, latest.current.length - 1);
1346
+ setStep(target);
1347
+ setVisited((prev) => /* @__PURE__ */ new Set([...prev, target]));
1348
+ return true;
1349
+ }, [validateStep, step]);
1350
+ const back = useCallback(() => {
1351
+ setStep((prev) => Math.max(prev - 1, 0));
1352
+ }, []);
1353
+ const goTo = useCallback(
1354
+ async (target) => {
1355
+ const count = latest.current.length;
1356
+ const clamped = Math.max(0, Math.min(target, count - 1));
1357
+ if (clamped <= step) {
1358
+ setStep(clamped);
1359
+ return clamped;
1360
+ }
1361
+ const walk = async (from) => {
1362
+ if (from >= clamped) return clamped;
1363
+ return await validateStep(from) ? walk(from + 1) : from;
1364
+ };
1365
+ const landed = await walk(step);
1366
+ setStep(landed);
1367
+ setVisited(
1368
+ (prev) => /* @__PURE__ */ new Set([
1369
+ ...prev,
1370
+ ...Array.from({ length: landed - step + 1 }, (_, i) => step + i)
1371
+ ])
1372
+ );
1373
+ return landed;
1374
+ },
1375
+ [validateStep, step]
1376
+ );
1377
+ const errorFlags = useFormSelectorShallow(
1378
+ form,
1379
+ (state) => steps.map(
1380
+ (definition) => definition.fields.some(
1381
+ (path) => Object.keys(state.errors).some((key) => isPathOrChild(key, path))
1382
+ )
1383
+ )
1384
+ );
1385
+ const statuses = useMemo(
1386
+ () => steps.map(
1387
+ (definition, index) => ({
1388
+ name: definition.name,
1389
+ visited: visited.has(index),
1390
+ hasErrors: errorFlags[index] ?? false
1391
+ })
1392
+ ),
1393
+ [steps, visited, errorFlags]
1394
+ );
1395
+ return useMemo(
1396
+ () => ({
1397
+ step,
1398
+ name: steps[step]?.name,
1399
+ count: steps.length,
1400
+ isFirst: step === 0,
1401
+ isLast: step === steps.length - 1,
1402
+ next,
1403
+ back,
1404
+ goTo,
1405
+ steps: statuses
1406
+ }),
1407
+ [step, steps, next, back, goTo, statuses]
1408
+ );
1409
+ };
1186
1410
  var warnUnobserved = (result, options, site) => {
1187
1411
  if (result.kind === "error" && options?.onError === void 0 && process.env.NODE_ENV !== "production") {
1188
1412
  console.warn(
@@ -1276,28 +1500,24 @@ function useField(form, pathArg, options) {
1276
1500
  return initialValue === null ? { emptyValue: null, clearable: true } : { emptyValue: void 0, clearable: false };
1277
1501
  }, [formSchema, path, initialValue]);
1278
1502
  const debounceMs = options?.debounceMs;
1279
- const debounceTimerRef = useRef(null);
1503
+ const debouncerRef = useRef(null);
1280
1504
  useEffect(() => {
1281
1505
  return () => {
1282
- if (debounceTimerRef.current !== null) {
1283
- clearTimeout(debounceTimerRef.current);
1284
- debounceTimerRef.current = null;
1285
- }
1506
+ debouncerRef.current?.cancel();
1507
+ debouncerRef.current = null;
1286
1508
  };
1287
1509
  }, [form, path, debounceMs]);
1288
1510
  const triggerValidate = useCallback(() => {
1289
1511
  if (debounceMs !== void 0 && debounceMs > 0) {
1290
- if (debounceTimerRef.current !== null) {
1291
- clearTimeout(debounceTimerRef.current);
1292
- }
1512
+ const debouncer = debouncerRef.current ?? createDebouncer(debounceMs);
1513
+ debouncerRef.current = debouncer;
1293
1514
  const scheduled = getAtPath(form.store.getState().values, path);
1294
- debounceTimerRef.current = setTimeout(() => {
1295
- debounceTimerRef.current = null;
1515
+ debouncer.schedule(() => {
1296
1516
  const state = form.store.getState();
1297
1517
  if (state.values === state.initialValues) return;
1298
1518
  if (!Object.is(getAtPath(state.values, path), scheduled)) return;
1299
1519
  void form.validateFieldAsync(path);
1300
- }, debounceMs);
1520
+ });
1301
1521
  return;
1302
1522
  }
1303
1523
  form.validateField(path);
@@ -1676,6 +1896,7 @@ var createFormHooks = (form, name) => {
1676
1896
  paths
1677
1897
  );
1678
1898
  const useBoundSubmitCount = () => useSubmitCount(structural);
1899
+ const useBoundSteps = (steps) => useFormSteps(form, steps);
1679
1900
  const prefix = capitalize(name ?? "");
1680
1901
  return {
1681
1902
  [`use${prefix}Field`]: useBoundField,
@@ -1690,103 +1911,10 @@ var createFormHooks = (form, name) => {
1690
1911
  [`use${prefix}IsSubmitting`]: useBoundIsSubmitting,
1691
1912
  [`use${prefix}SubmitCount`]: useBoundSubmitCount,
1692
1913
  [`use${prefix}Values`]: useBoundValues,
1693
- [`use${prefix}Fields`]: useBoundFields
1914
+ [`use${prefix}Fields`]: useBoundFields,
1915
+ [`use${prefix}Steps`]: useBoundSteps
1694
1916
  };
1695
1917
  };
1696
-
1697
- // src/react/inputProps.ts
1698
- var hasFieldError = (error) => error !== void 0 && error.length > 0;
1699
- var ariaInvalid = (field) => hasFieldError(field.error) ? true : void 0;
1700
- var numberToInputText = (value) => value === void 0 || value === null || Number.isNaN(value) ? "" : String(value);
1701
- var parseNumberText = (text) => {
1702
- if (text.trim() === "") return { kind: "empty" };
1703
- const parsed = Number(text);
1704
- return Number.isFinite(parsed) ? { kind: "number", value: parsed } : { kind: "invalid" };
1705
- };
1706
- var textInputProps = (field) => ({
1707
- name: field.path,
1708
- value: field.value ?? "",
1709
- "aria-invalid": ariaInvalid(field),
1710
- onChange: (e) => {
1711
- const text = e.target.value;
1712
- field.setValue(text === "" && field.clearable ? field.emptyValue : text);
1713
- },
1714
- onBlur: field.onBlur
1715
- });
1716
- var numberInputProps = (field) => ({
1717
- type: "number",
1718
- name: field.path,
1719
- value: numberToInputText(field.value),
1720
- "aria-invalid": ariaInvalid(field),
1721
- onChange: (e) => {
1722
- const parsed = parseNumberText(e.target.value);
1723
- if (parsed.kind === "invalid") return;
1724
- field.setValue(
1725
- parsed.kind === "number" ? parsed.value : field.emptyValue
1726
- );
1727
- },
1728
- onBlur: field.onBlur
1729
- });
1730
- var dateToInputText = (value) => {
1731
- if (value === void 0 || value === null || Number.isNaN(value.getTime())) {
1732
- return "";
1733
- }
1734
- const pad = (part) => String(part).padStart(2, "0");
1735
- return `${String(value.getFullYear()).padStart(4, "0")}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
1736
- };
1737
- var parseDateText = (text) => {
1738
- if (text.trim() === "") return { kind: "empty" };
1739
- const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
1740
- if (match === null) return { kind: "invalid" };
1741
- const [, year, month, day] = match;
1742
- const y = Number(year);
1743
- const date = new Date(y, Number(month) - 1, Number(day));
1744
- if (y < 100) date.setFullYear(y);
1745
- return date.getFullYear() === y && date.getMonth() === Number(month) - 1 && date.getDate() === Number(day) ? { kind: "date", value: date } : { kind: "invalid" };
1746
- };
1747
- var dateInputProps = (field) => ({
1748
- type: "date",
1749
- name: field.path,
1750
- value: dateToInputText(field.value),
1751
- "aria-invalid": ariaInvalid(field),
1752
- onChange: (e) => {
1753
- const parsed = parseDateText(e.target.value);
1754
- if (parsed.kind !== "date") {
1755
- field.setValue(field.emptyValue);
1756
- return;
1757
- }
1758
- const current = field.value;
1759
- const next = current instanceof Date && !Number.isNaN(current.getTime()) ? new Date(
1760
- parsed.value.getFullYear(),
1761
- parsed.value.getMonth(),
1762
- parsed.value.getDate(),
1763
- current.getHours(),
1764
- current.getMinutes(),
1765
- current.getSeconds(),
1766
- current.getMilliseconds()
1767
- ) : parsed.value;
1768
- field.setValue(next);
1769
- },
1770
- onBlur: field.onBlur
1771
- });
1772
- var checkboxProps = (field) => ({
1773
- type: "checkbox",
1774
- name: field.path,
1775
- checked: field.value ?? false,
1776
- "aria-invalid": ariaInvalid(field),
1777
- onChange: (e) => field.setValue(e.target.checked),
1778
- onBlur: field.onBlur
1779
- });
1780
- var selectProps = (field) => ({
1781
- name: field.path,
1782
- value: field.value ?? "",
1783
- "aria-invalid": ariaInvalid(field),
1784
- onChange: (e) => {
1785
- const v = e.target.value;
1786
- field.setValue(v === "" && field.clearable ? field.emptyValue : v);
1787
- },
1788
- onBlur: field.onBlur
1789
- });
1790
1918
  var useFieldA11y = (error) => {
1791
1919
  const id = useId();
1792
1920
  const errorId = `${id}-error`;
@@ -2079,6 +2207,6 @@ var focusField = (path, root) => {
2079
2207
  return path === "" ? (root !== void 0 || document.querySelectorAll("form").length <= 1) && focusFirstOf(namedControls(scope).filter(isFocusCandidate)) : focusFirstOf(pathCandidates(scope, [path]).filter(isFocusCandidate));
2080
2208
  };
2081
2209
 
2082
- 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, useFormAction, useFormActionState, useFormError, useFormValues, useIsDirty, useIsSubmitting, useIsValid, useIsValidating, useNumberInput, usePersistForm, useSubmitCount, useVariantField, validateAsync, validateSync };
2210
+ 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, textInputProps, useField, useFieldArray, useFields, useForm, useFormAction, useFormActionState, useFormError, useFormSteps, useFormValues, useIsDirty, useIsSubmitting, useIsValid, useIsValidating, useMaskedInput, useNumberInput, usePersistForm, useSubmitCount, useVariantField, validateAsync, validateSync };
2083
2211
  //# sourceMappingURL=index.js.map
2084
2212
  //# sourceMappingURL=index.js.map