formstand 0.8.0 → 0.10.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
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var vanilla = require('zustand/vanilla');
4
+ var middleware = require('zustand/middleware');
4
5
  var zod = require('zod');
5
6
  var react = require('react');
6
7
  var jsxRuntime = require('react/jsx-runtime');
@@ -19,6 +20,20 @@ var moveFromTo = (from, to) => (n) => {
19
20
  return n;
20
21
  };
21
22
  var swapIndices = (a, b) => (n) => n === a ? b : n === b ? a : n;
23
+ var invertMapper = (mapper, oldLength, newLength) => {
24
+ const oldByNew = new Map(
25
+ Array.from({ length: oldLength }, (_, oldIndex) => oldIndex).flatMap(
26
+ (oldIndex) => {
27
+ const newIndex = mapper(oldIndex);
28
+ return newIndex === null ? [] : [[newIndex, oldIndex]];
29
+ }
30
+ )
31
+ );
32
+ return Array.from(
33
+ { length: newLength },
34
+ (_, newIndex) => oldByNew.get(newIndex) ?? -1
35
+ );
36
+ };
22
37
  var indexAndTail = (rest) => {
23
38
  const dotIdx = rest.indexOf(".");
24
39
  const idxStr = dotIdx === -1 ? rest : rest.slice(0, dotIdx);
@@ -42,6 +57,53 @@ var reKeyByArrayPath = (map, basePath, mapper) => {
42
57
  );
43
58
  };
44
59
 
60
+ // src/core/arrayOpLog.ts
61
+ var OP_LOG_LIMIT = 16;
62
+ var MAX_LOG_PATHS = 256;
63
+ var composeOps = (first, second) => ({
64
+ from: first.from,
65
+ to: second.to,
66
+ mapping: second.mapping.map(
67
+ (mid) => mid === -1 ? -1 : first.mapping[mid] ?? -1
68
+ )
69
+ });
70
+ var logs = /* @__PURE__ */ new WeakMap();
71
+ var boundOps = (ops) => {
72
+ const [head, second] = ops;
73
+ if (ops.length <= OP_LOG_LIMIT || head === void 0 || second === void 0) {
74
+ return ops;
75
+ }
76
+ return head.to === second.from ? [composeOps(head, second), ...ops.slice(2)] : ops.slice(1);
77
+ };
78
+ var recordArrayOp = (store, path, record) => {
79
+ const existing = logs.get(store);
80
+ const byPath = existing ?? /* @__PURE__ */ new Map();
81
+ if (existing === void 0) logs.set(store, byPath);
82
+ const bounded = boundOps([...byPath.get(path) ?? [], record]);
83
+ byPath.delete(path);
84
+ byPath.set(path, bounded);
85
+ if (byPath.size > MAX_LOG_PATHS) {
86
+ const oldest = byPath.keys().next().value;
87
+ if (oldest !== void 0) byPath.delete(oldest);
88
+ }
89
+ };
90
+ var arrayOpsFor = (store, path) => logs.get(store)?.get(path) ?? [];
91
+ var clearArrayOps = (store, path) => {
92
+ logs.get(store)?.delete(path);
93
+ };
94
+ var clearArrayOpsUnder = (store, path, keepSelf = false) => {
95
+ const byPath = logs.get(store);
96
+ if (byPath === void 0) return;
97
+ if (path === "") {
98
+ byPath.clear();
99
+ return;
100
+ }
101
+ const related = [...byPath.keys()].filter(
102
+ (p) => p === path && !keepSelf || p.startsWith(`${path}.`)
103
+ );
104
+ related.forEach((p) => byPath.delete(p));
105
+ };
106
+
45
107
  // src/core/equality.ts
46
108
  var isPlainObject = (v) => {
47
109
  if (typeof v !== "object" || v === null) return false;
@@ -87,7 +149,18 @@ var isIndexSegment = (s) => {
87
149
  const n = Number(s);
88
150
  return Number.isInteger(n) && n >= 0 && String(n) === s;
89
151
  };
90
- var parsePath = (path) => path === "" ? [] : path.split(".").map((s) => isIndexSegment(s) ? Number(s) : s);
152
+ var PARSE_CACHE_MAX = 4096;
153
+ var parseCache = /* @__PURE__ */ new Map();
154
+ var parsePath = (path) => {
155
+ const cached = parseCache.get(path);
156
+ if (cached !== void 0) return cached;
157
+ const segments = Object.freeze(
158
+ path === "" ? [] : path.split(".").map((s) => isIndexSegment(s) ? Number(s) : s)
159
+ );
160
+ if (parseCache.size >= PARSE_CACHE_MAX) parseCache.clear();
161
+ parseCache.set(path, segments);
162
+ return segments;
163
+ };
91
164
  var isPlainRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
92
165
  var readSegment = (obj, segment) => {
93
166
  if (obj == null) return void 0;
@@ -100,8 +173,9 @@ var getAtPath = (obj, path) => parsePath(path).reduce(readSegment, obj);
100
173
  var slotAtPath = (obj, path) => {
101
174
  const walked = parsePath(path).reduce(
102
175
  (acc, segment) => {
103
- const outOfBounds = typeof segment === "number" && (Array.isArray(acc.current) ? segment >= acc.current.length : !isPlainRecord(acc.current));
104
- return !acc.ok || outOfBounds ? { ok: false, current: void 0 } : { ok: true, current: readSegment(acc.current, segment) };
176
+ const container = acc.current;
177
+ const addressable = typeof segment === "number" ? Array.isArray(container) ? segment < container.length : isPlainRecord(container) : isPlainRecord(container);
178
+ return !acc.ok || !addressable ? { ok: false, current: void 0 } : { ok: true, current: readSegment(container, segment) };
105
179
  },
106
180
  { ok: true, current: obj }
107
181
  );
@@ -165,7 +239,7 @@ var expandIssue = (issue, prefix) => {
165
239
  return [{ path: issuePathToFormPath(at), message: issue.message }];
166
240
  };
167
241
  var flattenIssues = (issues) => issues.flatMap((issue) => expandIssue(issue, [])).reduce((acc, { path, message }) => {
168
- const existing = acc[path] ?? [];
242
+ const existing = (Object.hasOwn(acc, path) ? acc[path] : void 0) ?? [];
169
243
  return existing.includes(message) ? acc : { ...acc, [path]: [...existing, message] };
170
244
  }, {});
171
245
  var validateSync = (schema, values) => {
@@ -265,7 +339,7 @@ var releaseAncestorEntries = (map, path) => {
265
339
  return kept.length === entries.length ? map : Object.fromEntries(kept);
266
340
  };
267
341
  var dirtyPathsOf = (values, initial, prefix) => {
268
- if (valuesEqual(values, initial)) return [];
342
+ if (Object.is(values, initial)) return [];
269
343
  if (isPlainObject(values) && isPlainObject(initial)) {
270
344
  const keys = /* @__PURE__ */ new Set([...Object.keys(values), ...Object.keys(initial)]);
271
345
  const childPaths = [...keys].flatMap(
@@ -275,9 +349,11 @@ var dirtyPathsOf = (values, initial, prefix) => {
275
349
  prefix === "" ? k : `${prefix}.${k}`
276
350
  )
277
351
  );
278
- return childPaths.length > 0 ? childPaths : [prefix];
352
+ if (childPaths.length > 0) return childPaths;
353
+ const sameKeySet = Object.keys(values).length === Object.keys(initial).length && Object.keys(values).every((k) => Object.hasOwn(initial, k));
354
+ return sameKeySet ? [] : [prefix];
279
355
  }
280
- return [prefix];
356
+ return valuesEqual(values, initial) ? [] : [prefix];
281
357
  };
282
358
  var FORM_SEQ_KEY = "__form__";
283
359
  var SUBMIT_SEQ_KEY = "__submit__";
@@ -296,7 +372,9 @@ var reuseErrorRefs = (next, prev) => {
296
372
  };
297
373
  var scopeSettledResult = (result, path, viaSubschema) => result.kind !== "invalid" ? emptyErrors : viaSubschema ? prefixErrorKeys(result.errors, path) : path === "" ? result.errors : pickScope(result.errors, [path]);
298
374
  var mergeErrorChannels = (schema, server) => {
299
- const extra = Object.entries(server).filter(([k]) => schema[k] === void 0);
375
+ const extra = Object.entries(server).filter(
376
+ ([k]) => !Object.hasOwn(schema, k)
377
+ );
300
378
  return extra.length === 0 ? schema : { ...schema, ...Object.fromEntries(extra) };
301
379
  };
302
380
  var errorChannels = (state, patch) => {
@@ -339,13 +417,27 @@ var createForm = (schema, options) => {
339
417
  mode: initialMode,
340
418
  reValidateMode: initialReValidateMode
341
419
  };
342
- const store = vanilla.createStore(() => initial);
420
+ const store = options.devtools === void 0 || options.devtools === false ? vanilla.createStore(() => initial) : vanilla.createStore()(
421
+ middleware.devtools(() => initial, {
422
+ name: typeof options.devtools === "string" ? options.devtools : "formstand",
423
+ // Non-production only, matching zustand's own devtools default:
424
+ // opting in during development must NOT stream every keystroke to
425
+ // an end user who happens to have the extension installed, where
426
+ // a module-singleton form's unbounded time-travel history is a
427
+ // real memory + serialization cost. Guarded for environments
428
+ // without process (a bundler defines NODE_ENV in real dev).
429
+ enabled: typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production"
430
+ })
431
+ );
343
432
  const commitFullPass = (state, next) => errorChannels(state, {
344
433
  schemaErrors: reuseErrorRefs(next, state.schemaErrors)
345
434
  });
346
435
  const validate = () => {
436
+ if (asyncScopes.has("")) {
437
+ return { kind: "pending", promise: validateAsyncOnForm() };
438
+ }
347
439
  try {
348
- const result = validateSync(schema, store.getState().values);
440
+ const result = validateSyncLatched("", schema, store.getState().values);
349
441
  store.setState((state) => ({
350
442
  ...state,
351
443
  ...commitFullPass(
@@ -362,33 +454,65 @@ var createForm = (schema, options) => {
362
454
  }
363
455
  };
364
456
  const validateFields = (paths) => {
365
- if (paths.length === 0) return true;
457
+ if (paths.length === 0) return { kind: "valid" };
366
458
  if (paths.some((p) => p === "")) {
367
459
  const result = validate();
368
- return result.kind === "pending" ? result.promise.then((r) => r.kind === "valid") : result.kind === "valid";
460
+ return result.kind === "pending" ? { kind: "pending", promise: result.promise.then(settledFormResult) } : settledFormResult(result);
461
+ }
462
+ const values = store.getState().values;
463
+ const scopes = paths.map((path) => ({
464
+ path,
465
+ scope: fieldScopeFor(path, values)
466
+ }));
467
+ const allViaSubschema = scopes.every(
468
+ ({ scope }) => scope.kind === "skip" || scope.kind === "parse" && scope.viaSubschema
469
+ );
470
+ const anyLatched = allViaSubschema ? scopes.some(
471
+ ({ path, scope }) => scope.kind === "parse" && asyncScopes.has(path)
472
+ ) : asyncScopes.has("");
473
+ if (anyLatched) {
474
+ return { kind: "pending", promise: validateFieldsAsync(paths) };
369
475
  }
370
476
  try {
371
- const result = validateSync(schema, store.getState().values);
477
+ if (allViaSubschema) {
478
+ const merged = scopes.reduce(
479
+ (acc, { path, scope }) => scope.kind === "skip" ? acc : {
480
+ ...acc,
481
+ ...scopeSettledResult(
482
+ validateSyncLatched(path, scope.schema, scope.value),
483
+ path,
484
+ true
485
+ )
486
+ },
487
+ {}
488
+ );
489
+ store.setState((state) => ({
490
+ ...state,
491
+ ...commitScopedErrors(state, merged, paths)
492
+ }));
493
+ return settledFields(merged);
494
+ }
495
+ const result = validateSyncLatched("", schema, values);
372
496
  const fullErrors = result.kind === "invalid" ? result.errors : emptyErrors;
497
+ const requestedErrors = pickScope(fullErrors, paths);
373
498
  store.setState((state) => ({
374
499
  ...state,
375
- ...errorChannels(state, {
376
- schemaErrors: reuseErrorRefs(
377
- mergeScopedErrors(
378
- state.schemaErrors,
379
- pickScope(fullErrors, paths),
380
- paths
381
- ),
382
- state.schemaErrors
383
- ),
384
- // A validation explicitly targeting these paths supersedes any
385
- // server verdict on them.
386
- serverErrors: omitScope(state.serverErrors, paths)
387
- })
500
+ ...commitScopedErrors(state, requestedErrors, paths)
388
501
  }));
389
- return Object.keys(fullErrors).every((k) => !inScopeOfAny(k, paths));
502
+ return settledFields(requestedErrors);
503
+ } catch (e) {
504
+ if (isAsyncRequiredError(e)) {
505
+ return { kind: "pending", promise: validateFieldsAsync(paths) };
506
+ }
507
+ throw e;
508
+ }
509
+ };
510
+ const asyncScopes = /* @__PURE__ */ new Set();
511
+ const validateSyncLatched = (key, scopeSchema, value) => {
512
+ try {
513
+ return validateSync(scopeSchema, value);
390
514
  } catch (e) {
391
- if (isAsyncRequiredError(e)) return validateFieldsAsync(paths);
515
+ if (isAsyncRequiredError(e)) asyncScopes.add(key);
392
516
  throw e;
393
517
  }
394
518
  };
@@ -418,15 +542,6 @@ var createForm = (schema, options) => {
418
542
  const slot = slotAtPath(values, path);
419
543
  return slot.exists ? { kind: "parse", schema: sub, value: slot.value, viaSubschema: true } : { kind: "skip" };
420
544
  };
421
- const scopedFieldErrors = (path) => {
422
- const values = store.getState().values;
423
- const scope = fieldScopeFor(path, values);
424
- return scope.kind === "skip" ? emptyErrors : scopeSettledResult(
425
- validateSync(scope.schema, scope.value),
426
- path,
427
- scope.viaSubschema
428
- );
429
- };
430
545
  const scopedFieldErrorsAsync = async (path, values) => {
431
546
  const scope = fieldScopeFor(path, values);
432
547
  return scope.kind === "skip" ? emptyErrors : scopeSettledResult(
@@ -439,17 +554,32 @@ var createForm = (schema, options) => {
439
554
  const messages = Object.values(scoped).flat();
440
555
  return messages.length === 0 ? { kind: "valid" } : { kind: "invalid", errors: messages };
441
556
  };
442
- const commitFieldErrors = (state, scoped, path) => path === "" ? commitFullPass(state, scoped) : errorChannels(state, {
557
+ const settledFields = (scoped) => Object.keys(scoped).length === 0 ? { kind: "valid" } : { kind: "invalid", errors: scoped };
558
+ const settledFormResult = (result) => settledFields(result.kind === "invalid" ? result.errors : emptyErrors);
559
+ const commitScopedErrors = (state, scoped, paths) => errorChannels(state, {
443
560
  schemaErrors: reuseErrorRefs(
444
- mergeScopedErrors(state.schemaErrors, scoped, [path]),
561
+ mergeScopedErrors(state.schemaErrors, scoped, paths),
445
562
  state.schemaErrors
446
563
  ),
447
- serverErrors: omitScope(state.serverErrors, [path])
564
+ serverErrors: omitScope(state.serverErrors, paths)
448
565
  });
566
+ const commitFieldErrors = (state, scoped, path) => path === "" ? commitFullPass(state, scoped) : commitScopedErrors(state, scoped, [path]);
449
567
  const validateField = (path) => {
450
568
  warnMissingSchemaPath("validateField", path);
569
+ const scope = fieldScopeFor(path, store.getState().values);
570
+ if (scope.kind === "parse" && asyncScopes.has(scope.viaSubschema ? path : "")) {
571
+ return { kind: "pending", promise: validateFieldAsync(path) };
572
+ }
451
573
  try {
452
- const scoped = scopedFieldErrors(path);
574
+ const scoped = scope.kind === "skip" ? emptyErrors : scopeSettledResult(
575
+ validateSyncLatched(
576
+ scope.viaSubschema ? path : "",
577
+ scope.schema,
578
+ scope.value
579
+ ),
580
+ path,
581
+ scope.viaSubschema
582
+ );
453
583
  store.setState((state) => ({
454
584
  ...state,
455
585
  ...commitFieldErrors(state, scoped, path)
@@ -502,10 +632,9 @@ var createForm = (schema, options) => {
502
632
  return result;
503
633
  };
504
634
  const validateFieldsAsync = async (paths) => {
505
- if (paths.length === 0) return true;
635
+ if (paths.length === 0) return { kind: "valid" };
506
636
  if (paths.some((p) => p === "")) {
507
- const result2 = await validateAsyncOnForm();
508
- return result2.kind === "valid";
637
+ return settledFormResult(await validateAsyncOnForm());
509
638
  }
510
639
  const passes = new Map(paths.map((p) => [p, claimPass(p)]));
511
640
  const valuesAtStart = store.getState().values;
@@ -518,9 +647,8 @@ var createForm = (schema, options) => {
518
647
  }));
519
648
  const result = await validateAsync(schema, valuesAtStart);
520
649
  const fullErrors = result.kind === "invalid" ? result.errors : emptyErrors;
521
- const requestedValid = Object.keys(fullErrors).every(
522
- (k) => !inScopeOfAny(k, paths)
523
- );
650
+ const requestedErrors = pickScope(fullErrors, paths);
651
+ const requested = settledFields(requestedErrors);
524
652
  const live = store.getState();
525
653
  const stillCurrent = paths.filter(
526
654
  (p) => sequences.get(p) === passes.get(p)
@@ -535,27 +663,21 @@ var createForm = (schema, options) => {
535
663
  )
536
664
  }));
537
665
  }
538
- return requestedValid;
666
+ return requested;
539
667
  }
540
668
  store.setState((state) => ({
541
669
  ...state,
542
- ...errorChannels(state, {
543
- schemaErrors: reuseErrorRefs(
544
- mergeScopedErrors(
545
- state.schemaErrors,
546
- pickScope(fullErrors, stillCurrent),
547
- stillCurrent
548
- ),
549
- state.schemaErrors
550
- ),
551
- serverErrors: omitScope(state.serverErrors, stillCurrent)
552
- }),
670
+ ...commitScopedErrors(
671
+ state,
672
+ pickScope(fullErrors, stillCurrent),
673
+ stillCurrent
674
+ ),
553
675
  isValidating: stillCurrent.reduce(
554
676
  (acc, p) => omitKey(acc, p),
555
677
  state.isValidating
556
678
  )
557
679
  }));
558
- return requestedValid;
680
+ return requested;
559
681
  };
560
682
  const validateFieldAsync = async (path) => {
561
683
  warnMissingSchemaPath("validateFieldAsync", path);
@@ -605,12 +727,21 @@ var createForm = (schema, options) => {
605
727
  );
606
728
  return;
607
729
  }
730
+ const arr = Array.isArray(current) ? current : [];
731
+ const next = nextArray(arr);
732
+ const written = setAtPath(store.getState().values, path, next);
733
+ clearArrayOpsUnder(store, path, true);
734
+ if (Array.isArray(current) && getAtPath(written, path) === next) {
735
+ recordArrayOp(store, path, {
736
+ from: current,
737
+ to: next,
738
+ mapping: invertMapper(mapper, current.length, next.length)
739
+ });
740
+ }
608
741
  store.setState((state) => {
609
- const live = getAtPath(state.values, path);
610
- const arr = Array.isArray(live) ? live : [];
611
742
  return {
612
743
  ...state,
613
- values: setAtPath(state.values, path, nextArray(arr)),
744
+ values: written,
614
745
  ...errorChannels(state, {
615
746
  schemaErrors: reKeyByArrayPath(state.schemaErrors, path, mapper),
616
747
  // Server entries on rows follow their rows through the index
@@ -751,24 +882,30 @@ var createForm = (schema, options) => {
751
882
  }
752
883
  });
753
884
  },
754
- setValue: (path, value) => store.setState((state) => ({
755
- ...state,
756
- values: setAtPath(state.values, path, value),
757
- ...errorChannels(state, {
758
- serverErrors: releaseSpine(state.serverErrors, path)
759
- })
760
- })),
761
- setValues: (next) => store.setState((state) => ({
762
- ...state,
763
- values: next,
764
- ...errorChannels(state, {
765
- serverErrors: releaseChangedSlices(
766
- state.serverErrors,
767
- state.values,
768
- next
769
- )
770
- })
771
- })),
885
+ setValue: (path, value) => {
886
+ clearArrayOpsUnder(store, path);
887
+ store.setState((state) => ({
888
+ ...state,
889
+ values: setAtPath(state.values, path, value),
890
+ ...errorChannels(state, {
891
+ serverErrors: releaseSpine(state.serverErrors, path)
892
+ })
893
+ }));
894
+ },
895
+ setValues: (next) => {
896
+ clearArrayOpsUnder(store, "");
897
+ store.setState((state) => ({
898
+ ...state,
899
+ values: next,
900
+ ...errorChannels(state, {
901
+ serverErrors: releaseChangedSlices(
902
+ state.serverErrors,
903
+ state.values,
904
+ next
905
+ )
906
+ })
907
+ }));
908
+ },
772
909
  setTouched: (path, touched = true) => store.setState((state) => ({
773
910
  ...state,
774
911
  touched: { ...state.touched, [path]: touched }
@@ -799,25 +936,32 @@ var createForm = (schema, options) => {
799
936
  })
800
937
  };
801
938
  }),
802
- updateState: (updater) => store.setState((state) => {
803
- const patch = updater(state);
804
- if (Object.keys(patch).length === 0) return state;
805
- if (patch.errors !== void 0 && patch.errors !== state.errors) {
806
- console.warn(
807
- "[formstand] `errors` is derived from schemaErrors/serverErrors \u2014 patch those channels instead; the direct `errors` patch is ignored."
808
- );
939
+ updateState: (updater) => {
940
+ const valuesBefore = store.getState().values;
941
+ store.setState((state) => {
942
+ const patch = updater(state);
943
+ if (Object.keys(patch).length === 0) return state;
944
+ if (patch.errors !== void 0 && patch.errors !== state.errors) {
945
+ console.warn(
946
+ "[formstand] `errors` is derived from schemaErrors/serverErrors \u2014 patch those channels instead; the direct `errors` patch is ignored."
947
+ );
948
+ }
949
+ const next = { ...state, ...patch };
950
+ return {
951
+ ...next,
952
+ ...errorChannels(state, {
953
+ schemaErrors: next.schemaErrors,
954
+ serverErrors: next.serverErrors
955
+ })
956
+ };
957
+ });
958
+ if (store.getState().values !== valuesBefore) {
959
+ clearArrayOpsUnder(store, "");
809
960
  }
810
- const next = { ...state, ...patch };
811
- return {
812
- ...next,
813
- ...errorChannels(state, {
814
- schemaErrors: next.schemaErrors,
815
- serverErrors: next.serverErrors
816
- })
817
- };
818
- }),
961
+ },
819
962
  adoptValues: (values) => {
820
963
  sequences.clear();
964
+ clearArrayOpsUnder(store, "");
821
965
  store.setState((state) => ({
822
966
  ...state,
823
967
  values,
@@ -835,6 +979,7 @@ var createForm = (schema, options) => {
835
979
  },
836
980
  reset: (nextInitial, resetOptions) => {
837
981
  sequences.clear();
982
+ clearArrayOpsUnder(store, "");
838
983
  store.setState((state) => {
839
984
  const init = nextInitial === void 0 ? state.initialValues : isPlainObject(state.initialValues) && isPlainObject(nextInitial) ? {
840
985
  ...state.initialValues,
@@ -856,23 +1001,33 @@ var createForm = (schema, options) => {
856
1001
  };
857
1002
  });
858
1003
  },
859
- resetField: (path) => store.setState((state) => ({
860
- ...state,
861
- values: setAtPath(
862
- state.values,
863
- path,
864
- getAtPath(state.initialValues, path)
865
- ),
866
- // A runtime "" path scopes the whole form (the imperative surface is
867
- // string-typed even though FieldPath excludes ""). Server entries
868
- // follow the setValue release contract (releaseSpine).
869
- ...errorChannels(state, {
870
- schemaErrors: path === "" ? emptyErrors : omitScope(state.schemaErrors, [path]),
871
- serverErrors: releaseSpine(state.serverErrors, path)
872
- }),
873
- touched: path === "" ? emptyBools : omitScope(state.touched, [path]),
874
- isValidating: path === "" ? emptyBools : omitScope(state.isValidating, [path])
875
- })),
1004
+ resetField: (path) => {
1005
+ const initialSlot = slotAtPath(
1006
+ store.getState().initialValues,
1007
+ path
1008
+ );
1009
+ if (!initialSlot.exists) {
1010
+ console.warn(
1011
+ `[formstand] resetField("${path}"): path has no initial value (e.g. an appended array row); value left unchanged, field state cleared.`
1012
+ );
1013
+ }
1014
+ if (initialSlot.exists) {
1015
+ clearArrayOpsUnder(store, path);
1016
+ }
1017
+ store.setState((state) => ({
1018
+ ...state,
1019
+ values: initialSlot.exists ? setAtPath(state.values, path, initialSlot.value) : state.values,
1020
+ // A runtime "" path scopes the whole form (the imperative surface is
1021
+ // string-typed even though FieldPath excludes ""). Server entries
1022
+ // follow the setValue release contract (releaseSpine).
1023
+ ...errorChannels(state, {
1024
+ schemaErrors: path === "" ? emptyErrors : omitScope(state.schemaErrors, [path]),
1025
+ serverErrors: releaseSpine(state.serverErrors, path)
1026
+ }),
1027
+ touched: path === "" ? emptyBools : omitScope(state.touched, [path]),
1028
+ isValidating: path === "" ? emptyBools : omitScope(state.isValidating, [path])
1029
+ }));
1030
+ },
876
1031
  validate,
877
1032
  validateField,
878
1033
  validateFields,
@@ -926,29 +1081,32 @@ var createForm = (schema, options) => {
926
1081
  return dirtyPathsOf(state.values, state.initialValues, "");
927
1082
  },
928
1083
  snapshot: () => store.getState(),
929
- restore: (snap) => store.setState(() => {
930
- const schemaErrors = snap.schemaErrors ?? emptyErrors;
931
- const serverErrors = snap.serverErrors ?? emptyErrors;
932
- return {
933
- ...snap,
934
- schemaErrors,
935
- serverErrors,
936
- errors: reuseErrorRefs(
937
- mergeErrorChannels(schemaErrors, serverErrors),
938
- snap.errors ?? emptyErrors
939
- ),
940
- // In-flight state is owned by live passes, never by snapshots: a
941
- // snapshotted flag has no pass left to clear it (the pass that set
942
- // it cleared its ORIGINAL entry when it settled), so restoring one
943
- // would stick it forever. isSubmitting follows the same rule — a
944
- // mid-submit snapshot restored later must not disable submit
945
- // buttons forever, so it reflects the LIVE submit count instead of
946
- // the snapshotted flag (a live pass's finally still resets it).
947
- isValidating: emptyBools,
948
- isValidatingForm: false,
949
- isSubmitting: inFlight.count > 0
950
- };
951
- })
1084
+ restore: (snap) => {
1085
+ clearArrayOpsUnder(store, "");
1086
+ store.setState(() => {
1087
+ const schemaErrors = snap.schemaErrors ?? emptyErrors;
1088
+ const serverErrors = snap.serverErrors ?? emptyErrors;
1089
+ return {
1090
+ ...snap,
1091
+ schemaErrors,
1092
+ serverErrors,
1093
+ errors: reuseErrorRefs(
1094
+ mergeErrorChannels(schemaErrors, serverErrors),
1095
+ snap.errors ?? emptyErrors
1096
+ ),
1097
+ // In-flight state is owned by live passes, never by snapshots: a
1098
+ // snapshotted flag has no pass left to clear it (the pass that set
1099
+ // it cleared its ORIGINAL entry when it settled), so restoring one
1100
+ // would stick it forever. isSubmitting follows the same rule — a
1101
+ // mid-submit snapshot restored later must not disable submit
1102
+ // buttons forever, so it reflects the LIVE submit count instead of
1103
+ // the snapshotted flag (a live pass's finally still resets it).
1104
+ isValidating: emptyBools,
1105
+ isValidatingForm: false,
1106
+ isSubmitting: inFlight.count > 0
1107
+ };
1108
+ });
1109
+ }
952
1110
  });
953
1111
  };
954
1112
 
@@ -968,6 +1126,74 @@ var shouldValidateOn = (trigger, mode, reValidateMode, submitAttempted, touched
968
1126
  return true;
969
1127
  }
970
1128
  };
1129
+
1130
+ // src/core/persist.ts
1131
+ var defaultStorage = () => {
1132
+ try {
1133
+ return typeof globalThis.localStorage === "undefined" ? null : globalThis.localStorage;
1134
+ } catch {
1135
+ return null;
1136
+ }
1137
+ };
1138
+ var persistForm = (form, options) => {
1139
+ const storage = options.storage ?? defaultStorage();
1140
+ const debounceMs = options.debounceMs ?? 300;
1141
+ const apply = options.apply ?? "adopt";
1142
+ const applyDraft = (values) => apply === "restore" ? form.setValues(values) : form.adoptValues(values);
1143
+ const restore = () => {
1144
+ try {
1145
+ const raw = storage === null ? null : storage.getItem(options.key);
1146
+ if (raw === null) return false;
1147
+ applyDraft(JSON.parse(raw));
1148
+ return true;
1149
+ } catch {
1150
+ return false;
1151
+ }
1152
+ };
1153
+ const writeDraft = (values) => {
1154
+ try {
1155
+ storage?.setItem(options.key, JSON.stringify(values));
1156
+ } catch {
1157
+ }
1158
+ };
1159
+ const timerRef = {
1160
+ current: null
1161
+ };
1162
+ const cancelPending = () => {
1163
+ if (timerRef.current !== null) {
1164
+ clearTimeout(timerRef.current);
1165
+ timerRef.current = null;
1166
+ }
1167
+ };
1168
+ if (apply !== "manual") {
1169
+ restore();
1170
+ }
1171
+ const unsubscribe = form.watchValues((values) => {
1172
+ cancelPending();
1173
+ if (debounceMs === 0) {
1174
+ writeDraft(values);
1175
+ } else {
1176
+ timerRef.current = setTimeout(() => {
1177
+ timerRef.current = null;
1178
+ writeDraft(values);
1179
+ }, debounceMs);
1180
+ }
1181
+ });
1182
+ return Object.freeze({
1183
+ restore,
1184
+ clear: () => {
1185
+ cancelPending();
1186
+ try {
1187
+ storage?.removeItem(options.key);
1188
+ } catch {
1189
+ }
1190
+ },
1191
+ dispose: () => {
1192
+ cancelPending();
1193
+ unsubscribe();
1194
+ }
1195
+ });
1196
+ };
971
1197
  var useForm = (schema, options) => {
972
1198
  const formRef = react.useRef(null);
973
1199
  if (formRef.current === null) {
@@ -1050,15 +1276,7 @@ function useField(form, pathArg, options) {
1050
1276
  }, debounceMs);
1051
1277
  return;
1052
1278
  }
1053
- try {
1054
- form.validateField(path);
1055
- } catch (e) {
1056
- if (isAsyncRequiredError(e)) {
1057
- void form.validateFieldAsync(path);
1058
- return;
1059
- }
1060
- throw e;
1061
- }
1279
+ form.validateField(path);
1062
1280
  }, [form, path, debounceMs]);
1063
1281
  const setValue = react.useCallback(
1064
1282
  (value) => {
@@ -1130,12 +1348,22 @@ function useField(form, pathArg, options) {
1130
1348
  [slice, emptyValue, setValue, setTouched, setError, clearError, validate, validateAsync2, onBlur]
1131
1349
  );
1132
1350
  }
1133
- var EMPTY_ID_STATE = { items: [], ids: [], counter: 0 };
1351
+
1352
+ // src/react/useVariantField.ts
1353
+ function useVariantField(form, unionPath, field) {
1354
+ return useField(
1355
+ form,
1356
+ `${unionPath}.${field}`
1357
+ );
1358
+ }
1134
1359
  var EMPTY_ITEMS = [];
1360
+ var mintNumbers = (needsMint) => new Map(
1361
+ needsMint.flatMap((mint, i) => mint ? [i] : []).map((position, k) => [position, k + 1])
1362
+ );
1135
1363
  var reconcileIds = (prev, nextItems) => {
1136
1364
  if (prev.items === nextItems) return prev;
1137
1365
  if (prev.items.length === nextItems.length && prev.items.every((item, i) => Object.is(item, nextItems[i]))) {
1138
- return prev;
1366
+ return { ...prev, items: nextItems };
1139
1367
  }
1140
1368
  const buckets = prev.items.reduce((acc, item, i) => {
1141
1369
  const bucket = acc.get(item);
@@ -1157,15 +1385,77 @@ var reconcileIds = (prev, nextItems) => {
1157
1385
  })
1158
1386
  );
1159
1387
  const reused = matchedIds.map((id, i) => id ?? leftoverAt.get(i) ?? null);
1160
- const mintCounts = reused.reduce((acc, id) => {
1161
- const count = acc[acc.length - 1] ?? 0;
1162
- return [...acc, id === null ? count + 1 : count];
1163
- }, []);
1164
- const minted = mintCounts[mintCounts.length - 1] ?? 0;
1388
+ const mints = mintNumbers(reused.map((id) => id === null));
1165
1389
  const ids = reused.map(
1166
- (id, i) => id ?? `__zfa_${prev.counter + (mintCounts[i] ?? 0)}`
1390
+ (id, i) => id ?? `${prev.prefix}${prev.counter + (mints.get(i) ?? 0)}`
1167
1391
  );
1168
- return { items: nextItems, ids, counter: prev.counter + minted };
1392
+ return {
1393
+ items: nextItems,
1394
+ ids,
1395
+ counter: prev.counter + mints.size,
1396
+ prefix: prev.prefix
1397
+ };
1398
+ };
1399
+ var mappedIdState = (prev, mapping, liveItems) => {
1400
+ if (liveItems.length !== mapping.length) return null;
1401
+ const aligned = mapping.every(
1402
+ (src, i) => src === -1 ? true : prev.ids[src] !== void 0 && Object.is(liveItems[i], prev.items[src])
1403
+ );
1404
+ if (!aligned) return null;
1405
+ const mints = mintNumbers(mapping.map((src) => src === -1));
1406
+ const ids = mapping.map(
1407
+ (src, i) => src === -1 ? `${prev.prefix}${prev.counter + (mints.get(i) ?? 0)}` : prev.ids[src]
1408
+ );
1409
+ return {
1410
+ items: liveItems,
1411
+ ids,
1412
+ counter: prev.counter + mints.size,
1413
+ prefix: prev.prefix
1414
+ };
1415
+ };
1416
+ var MAX_ID_PATHS = 4096;
1417
+ var sharedIdEntries = /* @__PURE__ */ new WeakMap();
1418
+ var sharedIdEntry = (store, path) => {
1419
+ const existingStore = sharedIdEntries.get(store);
1420
+ const forStore = existingStore ?? { byPath: /* @__PURE__ */ new Map(), seq: 1 };
1421
+ if (existingStore === void 0) sharedIdEntries.set(store, forStore);
1422
+ const existing = forStore.byPath.get(path);
1423
+ if (existing !== void 0) {
1424
+ forStore.byPath.delete(path);
1425
+ forStore.byPath.set(path, existing);
1426
+ return existing;
1427
+ }
1428
+ const created = {
1429
+ state: {
1430
+ items: EMPTY_ITEMS,
1431
+ ids: [],
1432
+ counter: 0,
1433
+ prefix: `__zfa_${forStore.seq}_`
1434
+ }
1435
+ };
1436
+ forStore.seq += 1;
1437
+ forStore.byPath.set(path, created);
1438
+ if (forStore.byPath.size > MAX_ID_PATHS) {
1439
+ const oldest = forStore.byPath.keys().next().value;
1440
+ if (oldest !== void 0) forStore.byPath.delete(oldest);
1441
+ }
1442
+ return created;
1443
+ };
1444
+ var walkOps = (start, items, ops) => {
1445
+ const walked = ops.reduce((acc, op) => {
1446
+ const base = acc.items === op.from ? acc : reconcileIds(acc, op.from);
1447
+ return mappedIdState(base, op.mapping, op.to) ?? base;
1448
+ }, start);
1449
+ return walked.items === items ? walked : reconcileIds(walked, items);
1450
+ };
1451
+ var deriveIds = (store, path, items) => {
1452
+ const entry = sharedIdEntry(store, path);
1453
+ if (entry.state.items === items) return entry.state;
1454
+ const ops = arrayOpsFor(store, path);
1455
+ if (ops.length > 0) clearArrayOps(store, path);
1456
+ const next = walkOps(entry.state, items, ops);
1457
+ entry.state = next;
1458
+ return next;
1169
1459
  };
1170
1460
  function useFieldArray(form, pathArg) {
1171
1461
  const path = react$1.useStore(
@@ -1184,13 +1474,7 @@ function useFieldArray(form, pathArg) {
1184
1474
  );
1185
1475
  const items = slice.items;
1186
1476
  const error = slice.error;
1187
- const [idEntry, setIdEntry] = react.useState({ form, path, state: EMPTY_ID_STATE });
1188
- const base = idEntry.form === form && idEntry.path === path ? idEntry.state : { ...EMPTY_ID_STATE, counter: idEntry.state.counter };
1189
- const nextIdState = reconcileIds(base, items);
1190
- if (idEntry.form !== form || idEntry.path !== path || idEntry.state !== nextIdState) {
1191
- setIdEntry({ form, path, state: nextIdState });
1192
- }
1193
- const ids = nextIdState.ids;
1477
+ const ids = deriveIds(form.store, path, items).ids;
1194
1478
  const push = react.useCallback(
1195
1479
  (item) => form.arrayPush(path, item),
1196
1480
  [form, path]
@@ -1271,6 +1555,11 @@ var capitalize = (name) => name.length === 0 ? name : name.charAt(0).toUpperCase
1271
1555
  var createFormHooks = (form, name) => {
1272
1556
  const structural = form;
1273
1557
  const useBoundField = (path, options) => useField(structural, path, options);
1558
+ const useBoundVariantField = (unionPath, field) => useVariantField(
1559
+ structural,
1560
+ unionPath,
1561
+ field
1562
+ );
1274
1563
  const useBoundFieldArray = (path) => useFieldArray(structural, path);
1275
1564
  const useBoundSelector = (selector) => useFormSelector(structural, selector);
1276
1565
  const useBoundSelectorShallow = (selector) => useFormSelectorShallow(structural, selector);
@@ -1282,6 +1571,7 @@ var createFormHooks = (form, name) => {
1282
1571
  const prefix = capitalize(name ?? "");
1283
1572
  return {
1284
1573
  [`use${prefix}Field`]: useBoundField,
1574
+ [`use${prefix}VariantField`]: useBoundVariantField,
1285
1575
  [`use${prefix}FieldArray`]: useBoundFieldArray,
1286
1576
  [`use${prefix}Selector`]: useBoundSelector,
1287
1577
  [`use${prefix}SelectorShallow`]: useBoundSelectorShallow,
@@ -1294,7 +1584,8 @@ var createFormHooks = (form, name) => {
1294
1584
  };
1295
1585
 
1296
1586
  // src/react/inputProps.ts
1297
- var ariaInvalid = (field) => field.error !== void 0 && field.error.length > 0 ? true : void 0;
1587
+ var hasFieldError = (error) => error !== void 0 && error.length > 0;
1588
+ var ariaInvalid = (field) => hasFieldError(field.error) ? true : void 0;
1298
1589
  var numberToInputText = (value) => value === void 0 || value === null || Number.isNaN(value) ? "" : String(value);
1299
1590
  var parseNumberText = (text) => {
1300
1591
  if (text.trim() === "") return { kind: "empty" };
@@ -1326,6 +1617,48 @@ var numberInputProps = (field) => ({
1326
1617
  },
1327
1618
  onBlur: field.onBlur
1328
1619
  });
1620
+ var dateToInputText = (value) => {
1621
+ if (value === void 0 || value === null || Number.isNaN(value.getTime())) {
1622
+ return "";
1623
+ }
1624
+ const pad = (part) => String(part).padStart(2, "0");
1625
+ return `${String(value.getFullYear()).padStart(4, "0")}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
1626
+ };
1627
+ var parseDateText = (text) => {
1628
+ if (text.trim() === "") return { kind: "empty" };
1629
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
1630
+ if (match === null) return { kind: "invalid" };
1631
+ const [, year, month, day] = match;
1632
+ const y = Number(year);
1633
+ const date = new Date(y, Number(month) - 1, Number(day));
1634
+ if (y < 100) date.setFullYear(y);
1635
+ return date.getFullYear() === y && date.getMonth() === Number(month) - 1 && date.getDate() === Number(day) ? { kind: "date", value: date } : { kind: "invalid" };
1636
+ };
1637
+ var dateInputProps = (field) => ({
1638
+ type: "date",
1639
+ name: field.path,
1640
+ value: dateToInputText(field.value),
1641
+ "aria-invalid": ariaInvalid(field),
1642
+ onChange: (e) => {
1643
+ const parsed = parseDateText(e.target.value);
1644
+ if (parsed.kind !== "date") {
1645
+ field.setValue(field.emptyValue);
1646
+ return;
1647
+ }
1648
+ const current = field.value;
1649
+ const next = current instanceof Date && !Number.isNaN(current.getTime()) ? new Date(
1650
+ parsed.value.getFullYear(),
1651
+ parsed.value.getMonth(),
1652
+ parsed.value.getDate(),
1653
+ current.getHours(),
1654
+ current.getMinutes(),
1655
+ current.getSeconds(),
1656
+ current.getMilliseconds()
1657
+ ) : parsed.value;
1658
+ field.setValue(next);
1659
+ },
1660
+ onBlur: field.onBlur
1661
+ });
1329
1662
  var checkboxProps = (field) => ({
1330
1663
  type: "checkbox",
1331
1664
  name: field.path,
@@ -1346,11 +1679,37 @@ var selectProps = (field) => ({
1346
1679
  },
1347
1680
  onBlur: field.onBlur
1348
1681
  });
1349
- var hasError = (error) => error !== void 0 && error.length > 0;
1682
+ var useFieldA11y = (error) => {
1683
+ const id = react.useId();
1684
+ const errorId = `${id}-error`;
1685
+ return {
1686
+ id,
1687
+ errorId,
1688
+ describedBy: hasFieldError(error) ? errorId : void 0
1689
+ };
1690
+ };
1350
1691
  var ErrorText = ({
1351
1692
  id,
1352
1693
  error
1353
- }) => hasError(error) ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "zf-error", id, role: "alert", children: error?.[0] }) : null;
1694
+ }) => hasFieldError(error) ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "zf-error", id, role: "alert", children: error?.[0] }) : null;
1695
+ var FieldShell = ({
1696
+ id,
1697
+ errorId,
1698
+ label,
1699
+ error,
1700
+ wrapLabel,
1701
+ children
1702
+ }) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "zf-field", children: [
1703
+ wrapLabel === true ? /* @__PURE__ */ jsxRuntime.jsxs("label", { htmlFor: id, className: "zf-label", children: [
1704
+ children,
1705
+ " ",
1706
+ label
1707
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1708
+ label !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: id, className: "zf-label", children: label }) : null,
1709
+ children
1710
+ ] }),
1711
+ /* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error })
1712
+ ] });
1354
1713
  var TextField = ({
1355
1714
  form,
1356
1715
  path,
@@ -1360,25 +1719,60 @@ var TextField = ({
1360
1719
  autoComplete,
1361
1720
  ref
1362
1721
  }) => {
1363
- const id = react.useId();
1364
- const errorId = `${id}-error`;
1365
1722
  const field = useField(form, path);
1366
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "zf-field", children: [
1367
- label !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: id, className: "zf-label", children: label }) : null,
1368
- /* @__PURE__ */ jsxRuntime.jsx(
1369
- "input",
1370
- {
1371
- id,
1372
- ref,
1373
- ...textInputProps(field),
1374
- type,
1375
- placeholder,
1376
- autoComplete,
1377
- "aria-describedby": hasError(field.error) ? errorId : void 0
1378
- }
1379
- ),
1380
- /* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error: field.error })
1381
- ] });
1723
+ const a11y = useFieldA11y(field.error);
1724
+ return /* @__PURE__ */ jsxRuntime.jsx(
1725
+ FieldShell,
1726
+ {
1727
+ id: a11y.id,
1728
+ errorId: a11y.errorId,
1729
+ label,
1730
+ error: field.error,
1731
+ children: /* @__PURE__ */ jsxRuntime.jsx(
1732
+ "input",
1733
+ {
1734
+ id: a11y.id,
1735
+ ref,
1736
+ ...textInputProps(field),
1737
+ type,
1738
+ placeholder,
1739
+ autoComplete,
1740
+ "aria-describedby": a11y.describedBy
1741
+ }
1742
+ )
1743
+ }
1744
+ );
1745
+ };
1746
+ var DateField = ({
1747
+ form,
1748
+ path,
1749
+ label,
1750
+ min,
1751
+ max,
1752
+ ref
1753
+ }) => {
1754
+ const field = useField(form, path);
1755
+ const a11y = useFieldA11y(field.error);
1756
+ return /* @__PURE__ */ jsxRuntime.jsx(
1757
+ FieldShell,
1758
+ {
1759
+ id: a11y.id,
1760
+ errorId: a11y.errorId,
1761
+ label,
1762
+ error: field.error,
1763
+ children: /* @__PURE__ */ jsxRuntime.jsx(
1764
+ "input",
1765
+ {
1766
+ id: a11y.id,
1767
+ ref,
1768
+ ...dateInputProps(field),
1769
+ min,
1770
+ max,
1771
+ "aria-describedby": a11y.describedBy
1772
+ }
1773
+ )
1774
+ }
1775
+ );
1382
1776
  };
1383
1777
  var IDLE_EDIT = { raw: null, pushed: void 0 };
1384
1778
  var useNumberInput = (field) => {
@@ -1392,7 +1786,7 @@ var useNumberInput = (field) => {
1392
1786
  name: field.path,
1393
1787
  value: raw ?? numberToInputText(field.value),
1394
1788
  inputMode: "decimal",
1395
- "aria-invalid": hasError(field.error) ? true : void 0,
1789
+ "aria-invalid": hasFieldError(field.error) ? true : void 0,
1396
1790
  onChange: (e) => {
1397
1791
  const text = e.target.value;
1398
1792
  const parsed = parseNumberText(text);
@@ -1425,25 +1819,29 @@ var NumberField = ({
1425
1819
  placeholder,
1426
1820
  ref
1427
1821
  }) => {
1428
- const id = react.useId();
1429
- const errorId = `${id}-error`;
1430
1822
  const field = useField(form, path);
1431
1823
  const binding = useNumberInput(field);
1432
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "zf-field", children: [
1433
- label !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: id, className: "zf-label", children: label }) : null,
1434
- /* @__PURE__ */ jsxRuntime.jsx(
1435
- "input",
1436
- {
1437
- id,
1438
- ref,
1439
- type: "text",
1440
- ...binding,
1441
- placeholder,
1442
- "aria-describedby": hasError(field.error) ? errorId : void 0
1443
- }
1444
- ),
1445
- /* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error: field.error })
1446
- ] });
1824
+ const a11y = useFieldA11y(field.error);
1825
+ return /* @__PURE__ */ jsxRuntime.jsx(
1826
+ FieldShell,
1827
+ {
1828
+ id: a11y.id,
1829
+ errorId: a11y.errorId,
1830
+ label,
1831
+ error: field.error,
1832
+ children: /* @__PURE__ */ jsxRuntime.jsx(
1833
+ "input",
1834
+ {
1835
+ id: a11y.id,
1836
+ ref,
1837
+ type: "text",
1838
+ ...binding,
1839
+ placeholder,
1840
+ "aria-describedby": a11y.describedBy
1841
+ }
1842
+ )
1843
+ }
1844
+ );
1447
1845
  };
1448
1846
  var CheckboxField = ({
1449
1847
  form,
@@ -1451,25 +1849,27 @@ var CheckboxField = ({
1451
1849
  label,
1452
1850
  ref
1453
1851
  }) => {
1454
- const id = react.useId();
1455
- const errorId = `${id}-error`;
1456
1852
  const field = useField(form, path);
1457
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "zf-field", children: [
1458
- /* @__PURE__ */ jsxRuntime.jsxs("label", { htmlFor: id, className: "zf-label", children: [
1459
- /* @__PURE__ */ jsxRuntime.jsx(
1853
+ const a11y = useFieldA11y(field.error);
1854
+ return /* @__PURE__ */ jsxRuntime.jsx(
1855
+ FieldShell,
1856
+ {
1857
+ id: a11y.id,
1858
+ errorId: a11y.errorId,
1859
+ label,
1860
+ error: field.error,
1861
+ wrapLabel: true,
1862
+ children: /* @__PURE__ */ jsxRuntime.jsx(
1460
1863
  "input",
1461
1864
  {
1462
- id,
1865
+ id: a11y.id,
1463
1866
  ref,
1464
1867
  ...checkboxProps(field),
1465
- "aria-describedby": hasError(field.error) ? errorId : void 0
1868
+ "aria-describedby": a11y.describedBy
1466
1869
  }
1467
- ),
1468
- " ",
1469
- label
1470
- ] }),
1471
- /* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error: field.error })
1472
- ] });
1870
+ )
1871
+ }
1872
+ );
1473
1873
  };
1474
1874
  var SelectField = ({
1475
1875
  form,
@@ -1479,43 +1879,48 @@ var SelectField = ({
1479
1879
  placeholder,
1480
1880
  ref
1481
1881
  }) => {
1482
- const id = react.useId();
1483
- const errorId = `${id}-error`;
1484
1882
  const field = useField(form, path);
1883
+ const a11y = useFieldA11y(field.error);
1884
+ const select = selectProps(field);
1485
1885
  const clearable = field.emptyValue === null;
1486
- const showEmptyOption = clearable || field.value === void 0 || field.value === null || placeholder !== void 0;
1487
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "zf-field", children: [
1488
- label !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: id, className: "zf-label", children: label }) : null,
1489
- /* @__PURE__ */ jsxRuntime.jsxs(
1490
- "select",
1491
- {
1492
- id,
1493
- ref,
1494
- ...selectProps(field),
1495
- "aria-describedby": hasError(field.error) ? errorId : void 0,
1496
- children: [
1497
- showEmptyOption ? /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", disabled: !clearable, children: placeholder ?? "" }) : null,
1498
- options.map((opt, index) => (
1499
- // Index-qualified: option lists may legitimately repeat a value
1500
- // (e.g. differently-labelled aliases), and bare value keys would
1501
- // collide.
1502
- /* @__PURE__ */ jsxRuntime.jsx("option", { value: opt.value, children: opt.label }, `${index}-${opt.value}`)
1503
- ))
1504
- ]
1505
- }
1506
- ),
1507
- /* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error: field.error })
1508
- ] });
1886
+ const hasExplicitEmptyOption = options.some((opt) => opt.value === "");
1887
+ const showEmptyOption = !hasExplicitEmptyOption && (clearable || select.value === "" || placeholder !== void 0);
1888
+ return /* @__PURE__ */ jsxRuntime.jsx(
1889
+ FieldShell,
1890
+ {
1891
+ id: a11y.id,
1892
+ errorId: a11y.errorId,
1893
+ label,
1894
+ error: field.error,
1895
+ children: /* @__PURE__ */ jsxRuntime.jsxs(
1896
+ "select",
1897
+ {
1898
+ id: a11y.id,
1899
+ ref,
1900
+ ...select,
1901
+ "aria-describedby": a11y.describedBy,
1902
+ children: [
1903
+ showEmptyOption ? /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", disabled: !clearable, children: placeholder ?? "" }) : null,
1904
+ options.map((opt, index) => (
1905
+ // Index-qualified: option lists may legitimately repeat a value
1906
+ // (e.g. differently-labelled aliases), and bare value keys would
1907
+ // collide.
1908
+ /* @__PURE__ */ jsxRuntime.jsx("option", { value: opt.value, children: opt.label }, `${index}-${opt.value}`)
1909
+ ))
1910
+ ]
1911
+ }
1912
+ )
1913
+ }
1914
+ );
1509
1915
  };
1510
1916
 
1511
1917
  // src/react/focusError.ts
1512
- var focusableControls = (scope) => [
1918
+ var namedControls = (scope) => [
1513
1919
  ...scope.querySelectorAll(
1514
1920
  "input[name], select[name], textarea[name]"
1515
1921
  )
1516
- ].filter(
1517
- (el) => !el.matches(':disabled, [type="hidden"]') && el.closest("dialog:not([open])") === null
1518
- );
1922
+ ];
1923
+ var isFocusCandidate = (el) => !el.matches(':disabled, [type="hidden"]') && el.closest("dialog:not([open])") === null;
1519
1924
  var holdsFocus = (el) => {
1520
1925
  const rootNode = el.getRootNode();
1521
1926
  const active = rootNode instanceof Document || rootNode instanceof ShadowRoot ? rootNode.activeElement : document.activeElement;
@@ -1535,20 +1940,21 @@ var focusFirstError = (errors, root) => {
1535
1940
  (k) => k !== "" && (errors[k]?.length ?? 0) > 0
1536
1941
  );
1537
1942
  const hasRootError = (errors[""]?.length ?? 0) > 0;
1538
- const controls = focusableControls(scope);
1539
- const fieldMatches = controls.filter(
1540
- (el) => nameMatchesAny(el, erroredPaths)
1541
- );
1943
+ const controls = namedControls(scope);
1944
+ const fieldMatches = controls.filter((el) => nameMatchesAny(el, erroredPaths)).filter(isFocusCandidate);
1542
1945
  const rootFallbackApplies = hasRootError && (root !== void 0 || document.querySelectorAll("form").length <= 1);
1543
- const candidates = fieldMatches.length > 0 ? fieldMatches : rootFallbackApplies ? controls : [];
1946
+ const candidates = fieldMatches.length > 0 ? fieldMatches : rootFallbackApplies ? controls.filter(isFocusCandidate) : [];
1544
1947
  return focusFirstOf(candidates);
1545
1948
  };
1546
1949
  var focusField = (path, root) => {
1547
- const controls = focusableControls(root ?? document);
1548
- return path === "" ? (root !== void 0 || document.querySelectorAll("form").length <= 1) && focusFirstOf(controls) : focusFirstOf(controls.filter((el) => nameMatchesAny(el, [path])));
1950
+ const controls = namedControls(root ?? document);
1951
+ return path === "" ? (root !== void 0 || document.querySelectorAll("form").length <= 1) && focusFirstOf(controls.filter(isFocusCandidate)) : focusFirstOf(
1952
+ controls.filter((el) => nameMatchesAny(el, [path])).filter(isFocusCandidate)
1953
+ );
1549
1954
  };
1550
1955
 
1551
1956
  exports.CheckboxField = CheckboxField;
1957
+ exports.DateField = DateField;
1552
1958
  exports.NumberField = NumberField;
1553
1959
  exports.SelectField = SelectField;
1554
1960
  exports.TextField = TextField;
@@ -1556,16 +1962,21 @@ exports.checkboxProps = checkboxProps;
1556
1962
  exports.createForm = createForm;
1557
1963
  exports.createFormContext = createFormContext;
1558
1964
  exports.createFormHooks = createFormHooks;
1965
+ exports.dateInputProps = dateInputProps;
1966
+ exports.dateToInputText = dateToInputText;
1559
1967
  exports.emptyValueForSchema = emptyValueForSchema;
1560
1968
  exports.flattenIssues = flattenIssues;
1561
1969
  exports.focusField = focusField;
1562
1970
  exports.focusFirstError = focusFirstError;
1563
1971
  exports.getAtPath = getAtPath;
1972
+ exports.hasFieldError = hasFieldError;
1564
1973
  exports.isAsyncRequiredError = isAsyncRequiredError;
1565
1974
  exports.numberInputProps = numberInputProps;
1566
1975
  exports.numberToInputText = numberToInputText;
1976
+ exports.parseDateText = parseDateText;
1567
1977
  exports.parseNumberText = parseNumberText;
1568
1978
  exports.parsePath = parsePath;
1979
+ exports.persistForm = persistForm;
1569
1980
  exports.selectProps = selectProps;
1570
1981
  exports.setAtPath = setAtPath;
1571
1982
  exports.shouldValidateOn = shouldValidateOn;
@@ -1580,6 +1991,7 @@ exports.useIsDirty = useIsDirty;
1580
1991
  exports.useIsSubmitting = useIsSubmitting;
1581
1992
  exports.useIsValid = useIsValid;
1582
1993
  exports.useSubmitCount = useSubmitCount;
1994
+ exports.useVariantField = useVariantField;
1583
1995
  exports.validateAsync = validateAsync;
1584
1996
  exports.validateSync = validateSync;
1585
1997
  //# sourceMappingURL=index.cjs.map