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