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.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
 
@@ -153,11 +153,18 @@ var PARSE_CACHE_MAX = 4096;
153
153
  var parseCache = /* @__PURE__ */ new Map();
154
154
  var parsePath = (path) => {
155
155
  const cached = parseCache.get(path);
156
- if (cached !== void 0) return cached;
156
+ if (cached !== void 0) {
157
+ parseCache.delete(path);
158
+ parseCache.set(path, cached);
159
+ return cached;
160
+ }
157
161
  const segments = Object.freeze(
158
162
  path === "" ? [] : path.split(".").map((s) => isIndexSegment(s) ? Number(s) : s)
159
163
  );
160
- if (parseCache.size >= PARSE_CACHE_MAX) parseCache.clear();
164
+ if (parseCache.size >= PARSE_CACHE_MAX) {
165
+ const oldest = parseCache.keys().next().value;
166
+ if (oldest !== void 0) parseCache.delete(oldest);
167
+ }
161
168
  parseCache.set(path, segments);
162
169
  return segments;
163
170
  };
@@ -962,27 +969,25 @@ var createForm = (schema, options) => {
962
969
  };
963
970
  }),
964
971
  updateState: (updater) => {
965
- const valuesBefore = store.getState().values;
966
- store.setState((state) => {
967
- const patch = updater(state);
968
- if (Object.keys(patch).length === 0) return state;
969
- if (patch.errors !== void 0 && patch.errors !== state.errors) {
970
- console.warn(
971
- "[formstand] `errors` is derived from schemaErrors/serverErrors \u2014 patch those channels instead; the direct `errors` patch is ignored."
972
- );
973
- }
974
- const next = { ...state, ...patch };
975
- return {
976
- ...next,
977
- ...errorChannels(state, {
978
- schemaErrors: next.schemaErrors,
979
- serverErrors: next.serverErrors
980
- })
981
- };
982
- });
983
- if (store.getState().values !== valuesBefore) {
972
+ const state = store.getState();
973
+ const patch = updater(state);
974
+ if (Object.keys(patch).length === 0) return;
975
+ if (patch.errors !== void 0 && patch.errors !== state.errors) {
976
+ console.warn(
977
+ "[formstand] `errors` is derived from schemaErrors/serverErrors \u2014 patch those channels instead; the direct `errors` patch is ignored."
978
+ );
979
+ }
980
+ const next = { ...state, ...patch };
981
+ if (next.values !== state.values) {
984
982
  clearArrayOpsUnder(store, "");
985
983
  }
984
+ store.setState(() => ({
985
+ ...next,
986
+ ...errorChannels(state, {
987
+ schemaErrors: next.schemaErrors,
988
+ serverErrors: next.serverErrors
989
+ })
990
+ }));
986
991
  },
987
992
  adoptValues: (values) => {
988
993
  sequences.clear();
@@ -1159,6 +1164,35 @@ var shouldValidateOn = (trigger, mode, reValidateMode, submitAttempted, touched
1159
1164
  }
1160
1165
  };
1161
1166
 
1167
+ // src/core/debounce.ts
1168
+ var createDebouncer = (ms) => {
1169
+ const timer = {
1170
+ current: null
1171
+ };
1172
+ const cancel = () => {
1173
+ if (timer.current !== null) {
1174
+ clearTimeout(timer.current);
1175
+ timer.current = null;
1176
+ }
1177
+ };
1178
+ const schedule = (run) => {
1179
+ cancel();
1180
+ if (ms === 0) {
1181
+ run();
1182
+ return;
1183
+ }
1184
+ timer.current = setTimeout(() => {
1185
+ timer.current = null;
1186
+ run();
1187
+ }, ms);
1188
+ };
1189
+ return Object.freeze({
1190
+ schedule,
1191
+ cancel,
1192
+ pending: () => timer.current !== null
1193
+ });
1194
+ };
1195
+
1162
1196
  // src/core/persist.ts
1163
1197
  var kindOf = (value) => value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
1164
1198
  var conflictsWith = (draft, reference) => {
@@ -1233,28 +1267,13 @@ var persistForm = (form, options) => {
1233
1267
  } catch {
1234
1268
  }
1235
1269
  };
1236
- const timerRef = {
1237
- current: null
1238
- };
1239
- const cancelPending = () => {
1240
- if (timerRef.current !== null) {
1241
- clearTimeout(timerRef.current);
1242
- timerRef.current = null;
1243
- }
1244
- };
1270
+ const writes = createDebouncer(debounceMs);
1271
+ const cancelPending = () => writes.cancel();
1245
1272
  if (apply !== "manual") {
1246
1273
  load();
1247
1274
  }
1248
1275
  const unsubscribe = form.watchValues((values) => {
1249
- cancelPending();
1250
- if (debounceMs === 0) {
1251
- writeDraft(values);
1252
- } else {
1253
- timerRef.current = setTimeout(() => {
1254
- timerRef.current = null;
1255
- writeDraft(values);
1256
- }, debounceMs);
1257
- }
1276
+ writes.schedule(() => writeDraft(values));
1258
1277
  });
1259
1278
  return Object.freeze({
1260
1279
  load,
@@ -1317,6 +1336,241 @@ var useForm = (schema, options) => {
1317
1336
  }, [form, schema, options.initialValues]);
1318
1337
  return form;
1319
1338
  };
1339
+
1340
+ // src/react/inputProps.ts
1341
+ var hasFieldError = (error) => error !== void 0 && error.length > 0;
1342
+ var ariaInvalid = (field) => hasFieldError(field.error) ? true : void 0;
1343
+ var numberToInputText = (value) => value === void 0 || value === null || Number.isNaN(value) ? "" : String(value);
1344
+ var parseNumberText = (text) => {
1345
+ if (text.trim() === "") return { kind: "empty" };
1346
+ const parsed = Number(text);
1347
+ return Number.isFinite(parsed) ? { kind: "number", value: parsed } : { kind: "invalid" };
1348
+ };
1349
+ var textInputProps = (field) => ({
1350
+ name: field.path,
1351
+ value: field.value ?? "",
1352
+ "aria-invalid": ariaInvalid(field),
1353
+ onChange: (e) => {
1354
+ const text = e.target.value;
1355
+ field.setValue(text === "" && field.clearable ? field.emptyValue : text);
1356
+ },
1357
+ onBlur: field.onBlur
1358
+ });
1359
+ var numberInputProps = (field) => ({
1360
+ type: "number",
1361
+ name: field.path,
1362
+ value: numberToInputText(field.value),
1363
+ "aria-invalid": ariaInvalid(field),
1364
+ onChange: (e) => {
1365
+ const parsed = parseNumberText(e.target.value);
1366
+ if (parsed.kind === "invalid") return;
1367
+ field.setValue(
1368
+ parsed.kind === "number" ? parsed.value : field.emptyValue
1369
+ );
1370
+ },
1371
+ onBlur: field.onBlur
1372
+ });
1373
+ var dateToInputText = (value) => {
1374
+ if (value === void 0 || value === null || Number.isNaN(value.getTime())) {
1375
+ return "";
1376
+ }
1377
+ const pad = (part) => String(part).padStart(2, "0");
1378
+ return `${String(value.getFullYear()).padStart(4, "0")}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
1379
+ };
1380
+ var parseDateText = (text) => {
1381
+ if (text.trim() === "") return { kind: "empty" };
1382
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
1383
+ if (match === null) return { kind: "invalid" };
1384
+ const [, year, month, day] = match;
1385
+ const y = Number(year);
1386
+ const date = new Date(y, Number(month) - 1, Number(day));
1387
+ if (y < 100) date.setFullYear(y);
1388
+ return date.getFullYear() === y && date.getMonth() === Number(month) - 1 && date.getDate() === Number(day) ? { kind: "date", value: date } : { kind: "invalid" };
1389
+ };
1390
+ var dateInputProps = (field) => ({
1391
+ type: "date",
1392
+ name: field.path,
1393
+ value: dateToInputText(field.value),
1394
+ "aria-invalid": ariaInvalid(field),
1395
+ onChange: (e) => {
1396
+ const parsed = parseDateText(e.target.value);
1397
+ if (parsed.kind !== "date") {
1398
+ field.setValue(field.emptyValue);
1399
+ return;
1400
+ }
1401
+ const current = field.value;
1402
+ const next = current instanceof Date && !Number.isNaN(current.getTime()) ? new Date(
1403
+ parsed.value.getFullYear(),
1404
+ parsed.value.getMonth(),
1405
+ parsed.value.getDate(),
1406
+ current.getHours(),
1407
+ current.getMinutes(),
1408
+ current.getSeconds(),
1409
+ current.getMilliseconds()
1410
+ ) : parsed.value;
1411
+ field.setValue(next);
1412
+ },
1413
+ onBlur: field.onBlur
1414
+ });
1415
+ var checkboxProps = (field) => ({
1416
+ type: "checkbox",
1417
+ name: field.path,
1418
+ checked: field.value ?? false,
1419
+ "aria-invalid": ariaInvalid(field),
1420
+ onChange: (e) => field.setValue(e.target.checked),
1421
+ onBlur: field.onBlur
1422
+ });
1423
+ var selectProps = (field) => ({
1424
+ name: field.path,
1425
+ value: field.value ?? "",
1426
+ "aria-invalid": ariaInvalid(field),
1427
+ onChange: (e) => {
1428
+ const v = e.target.value;
1429
+ field.setValue(v === "" && field.clearable ? field.emptyValue : v);
1430
+ },
1431
+ onBlur: field.onBlur
1432
+ });
1433
+
1434
+ // src/react/useMaskedInput.ts
1435
+ var useMaskedInput = (field, options) => {
1436
+ const [edit, setEdit] = react.useState({
1437
+ raw: null,
1438
+ pushed: void 0
1439
+ });
1440
+ const externallyChanged = edit.raw !== null && !Object.is(field.value, edit.pushed);
1441
+ if (externallyChanged) {
1442
+ setEdit({ raw: null, pushed: void 0 });
1443
+ }
1444
+ const raw = externallyChanged ? null : edit.raw;
1445
+ return {
1446
+ name: field.path,
1447
+ value: raw ?? (field.value === void 0 || field.value === null ? "" : options.format(field.value)),
1448
+ "aria-invalid": hasFieldError(field.error) ? true : void 0,
1449
+ onChange: (e) => {
1450
+ const text = e.target.value;
1451
+ const parsed = options.parse(text);
1452
+ switch (parsed.kind) {
1453
+ case "value":
1454
+ setEdit({ raw: text, pushed: parsed.value });
1455
+ field.setValue(parsed.value);
1456
+ return;
1457
+ case "empty": {
1458
+ const empty = field.emptyValue;
1459
+ setEdit({ raw: text, pushed: empty });
1460
+ field.setValue(empty);
1461
+ return;
1462
+ }
1463
+ case "invalid":
1464
+ setEdit({ raw: text, pushed: field.value });
1465
+ return;
1466
+ }
1467
+ },
1468
+ onBlur: () => {
1469
+ setEdit({ raw: null, pushed: void 0 });
1470
+ field.onBlur();
1471
+ }
1472
+ };
1473
+ };
1474
+ function useFormSelector(form, selector) {
1475
+ return react$1.useStore(form.store, selector);
1476
+ }
1477
+ function useFormSelectorShallow(form, selector) {
1478
+ return react$1.useStore(form.store, shallow.useShallow(selector));
1479
+ }
1480
+
1481
+ // src/react/useFormSteps.ts
1482
+ var useFormSteps = (form, steps) => {
1483
+ const [step, setStep] = react.useState(0);
1484
+ const [visited, setVisited] = react.useState(
1485
+ () => /* @__PURE__ */ new Set([0])
1486
+ );
1487
+ const latest = react.useRef(steps);
1488
+ react.useEffect(() => {
1489
+ latest.current = steps;
1490
+ });
1491
+ const validateStep = react.useCallback(
1492
+ async (index) => {
1493
+ const definition = latest.current[index];
1494
+ if (definition === void 0) return true;
1495
+ const result = await form.validateFieldsAsync(definition.fields);
1496
+ if (result.kind === "invalid") {
1497
+ Object.keys(result.errors).forEach((key) => {
1498
+ form.setTouched(key, true);
1499
+ });
1500
+ return false;
1501
+ }
1502
+ return true;
1503
+ },
1504
+ [form]
1505
+ );
1506
+ const next = react.useCallback(async () => {
1507
+ const ok = await validateStep(step);
1508
+ if (!ok) return false;
1509
+ const target = Math.min(step + 1, latest.current.length - 1);
1510
+ setStep(target);
1511
+ setVisited((prev) => /* @__PURE__ */ new Set([...prev, target]));
1512
+ return true;
1513
+ }, [validateStep, step]);
1514
+ const back = react.useCallback(() => {
1515
+ setStep((prev) => Math.max(prev - 1, 0));
1516
+ }, []);
1517
+ const goTo = react.useCallback(
1518
+ async (target) => {
1519
+ const count = latest.current.length;
1520
+ const clamped = Math.max(0, Math.min(target, count - 1));
1521
+ if (clamped <= step) {
1522
+ setStep(clamped);
1523
+ return clamped;
1524
+ }
1525
+ const walk = async (from) => {
1526
+ if (from >= clamped) return clamped;
1527
+ return await validateStep(from) ? walk(from + 1) : from;
1528
+ };
1529
+ const landed = await walk(step);
1530
+ setStep(landed);
1531
+ setVisited(
1532
+ (prev) => /* @__PURE__ */ new Set([
1533
+ ...prev,
1534
+ ...Array.from({ length: landed - step + 1 }, (_, i) => step + i)
1535
+ ])
1536
+ );
1537
+ return landed;
1538
+ },
1539
+ [validateStep, step]
1540
+ );
1541
+ const errorFlags = useFormSelectorShallow(
1542
+ form,
1543
+ (state) => steps.map(
1544
+ (definition) => definition.fields.some(
1545
+ (path) => Object.keys(state.errors).some((key) => isPathOrChild(key, path))
1546
+ )
1547
+ )
1548
+ );
1549
+ const statuses = react.useMemo(
1550
+ () => steps.map(
1551
+ (definition, index) => ({
1552
+ name: definition.name,
1553
+ visited: visited.has(index),
1554
+ hasErrors: errorFlags[index] ?? false
1555
+ })
1556
+ ),
1557
+ [steps, visited, errorFlags]
1558
+ );
1559
+ return react.useMemo(
1560
+ () => ({
1561
+ step,
1562
+ name: steps[step]?.name,
1563
+ count: steps.length,
1564
+ isFirst: step === 0,
1565
+ isLast: step === steps.length - 1,
1566
+ next,
1567
+ back,
1568
+ goTo,
1569
+ steps: statuses
1570
+ }),
1571
+ [step, steps, next, back, goTo, statuses]
1572
+ );
1573
+ };
1320
1574
  var warnUnobserved = (result, options, site) => {
1321
1575
  if (result.kind === "error" && options?.onError === void 0 && process.env.NODE_ENV !== "production") {
1322
1576
  console.warn(
@@ -1410,28 +1664,24 @@ function useField(form, pathArg, options) {
1410
1664
  return initialValue === null ? { emptyValue: null, clearable: true } : { emptyValue: void 0, clearable: false };
1411
1665
  }, [formSchema, path, initialValue]);
1412
1666
  const debounceMs = options?.debounceMs;
1413
- const debounceTimerRef = react.useRef(null);
1667
+ const debouncerRef = react.useRef(null);
1414
1668
  react.useEffect(() => {
1415
1669
  return () => {
1416
- if (debounceTimerRef.current !== null) {
1417
- clearTimeout(debounceTimerRef.current);
1418
- debounceTimerRef.current = null;
1419
- }
1670
+ debouncerRef.current?.cancel();
1671
+ debouncerRef.current = null;
1420
1672
  };
1421
1673
  }, [form, path, debounceMs]);
1422
1674
  const triggerValidate = react.useCallback(() => {
1423
1675
  if (debounceMs !== void 0 && debounceMs > 0) {
1424
- if (debounceTimerRef.current !== null) {
1425
- clearTimeout(debounceTimerRef.current);
1426
- }
1676
+ const debouncer = debouncerRef.current ?? createDebouncer(debounceMs);
1677
+ debouncerRef.current = debouncer;
1427
1678
  const scheduled = getAtPath(form.store.getState().values, path);
1428
- debounceTimerRef.current = setTimeout(() => {
1429
- debounceTimerRef.current = null;
1679
+ debouncer.schedule(() => {
1430
1680
  const state = form.store.getState();
1431
1681
  if (state.values === state.initialValues) return;
1432
1682
  if (!Object.is(getAtPath(state.values, path), scheduled)) return;
1433
1683
  void form.validateFieldAsync(path);
1434
- }, debounceMs);
1684
+ });
1435
1685
  return;
1436
1686
  }
1437
1687
  form.validateField(path);
@@ -1785,12 +2035,6 @@ function useIsValidating(form, path) {
1785
2035
  }
1786
2036
  var useIsSubmitting = (form) => react$1.useStore(form.store, (state) => state.isSubmitting);
1787
2037
  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
2038
 
1795
2039
  // src/react/createFormHooks.ts
1796
2040
  var capitalize = (name) => name.length === 0 ? name : name.charAt(0).toUpperCase() + name.slice(1);
@@ -1816,6 +2060,7 @@ var createFormHooks = (form, name) => {
1816
2060
  paths
1817
2061
  );
1818
2062
  const useBoundSubmitCount = () => useSubmitCount(structural);
2063
+ const useBoundSteps = (steps) => useFormSteps(form, steps);
1819
2064
  const prefix = capitalize(name ?? "");
1820
2065
  return {
1821
2066
  [`use${prefix}Field`]: useBoundField,
@@ -1830,103 +2075,10 @@ var createFormHooks = (form, name) => {
1830
2075
  [`use${prefix}IsSubmitting`]: useBoundIsSubmitting,
1831
2076
  [`use${prefix}SubmitCount`]: useBoundSubmitCount,
1832
2077
  [`use${prefix}Values`]: useBoundValues,
1833
- [`use${prefix}Fields`]: useBoundFields
2078
+ [`use${prefix}Fields`]: useBoundFields,
2079
+ [`use${prefix}Steps`]: useBoundSteps
1834
2080
  };
1835
2081
  };
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
2082
  var useFieldA11y = (error) => {
1931
2083
  const id = react.useId();
1932
2084
  const errorId = `${id}-error`;
@@ -2257,11 +2409,13 @@ exports.useFormActionState = useFormActionState;
2257
2409
  exports.useFormError = useFormError;
2258
2410
  exports.useFormSelector = useFormSelector;
2259
2411
  exports.useFormSelectorShallow = useFormSelectorShallow;
2412
+ exports.useFormSteps = useFormSteps;
2260
2413
  exports.useFormValues = useFormValues;
2261
2414
  exports.useIsDirty = useIsDirty;
2262
2415
  exports.useIsSubmitting = useIsSubmitting;
2263
2416
  exports.useIsValid = useIsValid;
2264
2417
  exports.useIsValidating = useIsValidating;
2418
+ exports.useMaskedInput = useMaskedInput;
2265
2419
  exports.useNumberInput = useNumberInput;
2266
2420
  exports.usePersistForm = usePersistForm;
2267
2421
  exports.useSubmitCount = useSubmitCount;