@lucas-barake/effect-form 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist/{dts/Field.d.ts → Field.d.ts} +1 -0
  2. package/dist/Field.d.ts.map +1 -0
  3. package/dist/{esm/Field.js → Field.js} +29 -0
  4. package/dist/Field.js.map +1 -0
  5. package/dist/FieldState.d.ts +20 -0
  6. package/dist/FieldState.d.ts.map +1 -0
  7. package/dist/FieldState.js +2 -0
  8. package/dist/FieldState.js.map +1 -0
  9. package/dist/{dts/FormAtoms.d.ts → FormAtoms.d.ts} +26 -10
  10. package/dist/FormAtoms.d.ts.map +1 -0
  11. package/dist/{esm/FormAtoms.js → FormAtoms.js} +224 -17
  12. package/dist/FormAtoms.js.map +1 -0
  13. package/dist/{dts/FormBuilder.d.ts → FormBuilder.d.ts} +1 -1
  14. package/dist/FormBuilder.d.ts.map +1 -0
  15. package/dist/FormBuilder.js.map +1 -0
  16. package/dist/Mode.d.ts +34 -0
  17. package/dist/Mode.d.ts.map +1 -0
  18. package/dist/Mode.js +26 -0
  19. package/dist/Mode.js.map +1 -0
  20. package/dist/Path.d.ts.map +1 -0
  21. package/dist/Path.js.map +1 -0
  22. package/dist/Validation.d.ts.map +1 -0
  23. package/dist/{esm/Validation.js → Validation.js} +69 -36
  24. package/dist/Validation.js.map +1 -0
  25. package/dist/index.d.ts +8 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +8 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/internal/dirty.d.ts.map +1 -0
  30. package/dist/internal/dirty.js.map +1 -0
  31. package/dist/internal/weak-registry.d.ts.map +1 -0
  32. package/dist/internal/weak-registry.js.map +1 -0
  33. package/package.json +33 -66
  34. package/src/Field.ts +82 -50
  35. package/src/FieldState.ts +22 -0
  36. package/src/FormAtoms.ts +554 -271
  37. package/src/FormBuilder.ts +102 -102
  38. package/src/Mode.ts +23 -23
  39. package/src/Path.ts +38 -38
  40. package/src/Validation.ts +108 -72
  41. package/src/index.ts +7 -28
  42. package/src/internal/dirty.ts +43 -43
  43. package/src/internal/weak-registry.ts +21 -21
  44. package/Field/package.json +0 -6
  45. package/FormAtoms/package.json +0 -6
  46. package/FormBuilder/package.json +0 -6
  47. package/Mode/package.json +0 -6
  48. package/Path/package.json +0 -6
  49. package/Validation/package.json +0 -6
  50. package/dist/cjs/Field.js +0 -94
  51. package/dist/cjs/Field.js.map +0 -1
  52. package/dist/cjs/FormAtoms.js +0 -362
  53. package/dist/cjs/FormAtoms.js.map +0 -1
  54. package/dist/cjs/FormBuilder.js +0 -107
  55. package/dist/cjs/FormBuilder.js.map +0 -1
  56. package/dist/cjs/Mode.js +0 -52
  57. package/dist/cjs/Mode.js.map +0 -1
  58. package/dist/cjs/Path.js +0 -71
  59. package/dist/cjs/Path.js.map +0 -1
  60. package/dist/cjs/Validation.js +0 -140
  61. package/dist/cjs/Validation.js.map +0 -1
  62. package/dist/cjs/index.js +0 -39
  63. package/dist/cjs/index.js.map +0 -1
  64. package/dist/cjs/internal/dirty.js +0 -108
  65. package/dist/cjs/internal/dirty.js.map +0 -1
  66. package/dist/cjs/internal/weak-registry.js +0 -41
  67. package/dist/cjs/internal/weak-registry.js.map +0 -1
  68. package/dist/dts/Field.d.ts.map +0 -1
  69. package/dist/dts/FormAtoms.d.ts.map +0 -1
  70. package/dist/dts/FormBuilder.d.ts.map +0 -1
  71. package/dist/dts/Mode.d.ts +0 -29
  72. package/dist/dts/Mode.d.ts.map +0 -1
  73. package/dist/dts/Path.d.ts.map +0 -1
  74. package/dist/dts/Validation.d.ts.map +0 -1
  75. package/dist/dts/index.d.ts +0 -25
  76. package/dist/dts/index.d.ts.map +0 -1
  77. package/dist/dts/internal/dirty.d.ts.map +0 -1
  78. package/dist/dts/internal/weak-registry.d.ts.map +0 -1
  79. package/dist/esm/Field.js.map +0 -1
  80. package/dist/esm/FormAtoms.js.map +0 -1
  81. package/dist/esm/FormBuilder.js.map +0 -1
  82. package/dist/esm/Mode.js +0 -25
  83. package/dist/esm/Mode.js.map +0 -1
  84. package/dist/esm/Path.js.map +0 -1
  85. package/dist/esm/Validation.js.map +0 -1
  86. package/dist/esm/index.js +0 -25
  87. package/dist/esm/index.js.map +0 -1
  88. package/dist/esm/internal/dirty.js.map +0 -1
  89. package/dist/esm/internal/weak-registry.js.map +0 -1
  90. package/dist/esm/package.json +0 -4
  91. /package/dist/{esm/FormBuilder.js → FormBuilder.js} +0 -0
  92. /package/dist/{dts/Path.d.ts → Path.d.ts} +0 -0
  93. /package/dist/{esm/Path.js → Path.js} +0 -0
  94. /package/dist/{dts/Validation.d.ts → Validation.d.ts} +0 -0
  95. /package/dist/{dts/internal → internal}/dirty.d.ts +0 -0
  96. /package/dist/{esm/internal → internal}/dirty.js +0 -0
  97. /package/dist/{dts/internal → internal}/weak-registry.d.ts +0 -0
  98. /package/dist/{esm/internal → internal}/weak-registry.js +0 -0
package/src/FormAtoms.ts CHANGED
@@ -1,88 +1,107 @@
1
- import * as Atom from "@effect-atom/atom/Atom";
2
- import * as Effect from "effect/Effect";
3
- import { pipe } from "effect/Function";
4
- import * as Option from "effect/Option";
5
- import type * as ParseResult from "effect/ParseResult";
6
- import * as Schema from "effect/Schema";
7
- import * as Field from "./Field.js";
8
- import * as FormBuilder from "./FormBuilder.js";
9
- import { recalculateDirtyFieldsForArray, recalculateDirtySubtree } from "./internal/dirty.js";
10
- import { createWeakRegistry, type WeakRegistry } from "./internal/weak-registry.js";
11
- import { getNestedValue, isPathOrParentDirty, setNestedValue } from "./Path.js";
12
- import * as Validation from "./Validation.js";
1
+ import * as Atom from "@effect-atom/atom/Atom"
2
+ import * as Cause from "effect/Cause"
3
+ import * as Effect from "effect/Effect"
4
+ import { pipe } from "effect/Function"
5
+ import * as Option from "effect/Option"
6
+ import * as ParseResult from "effect/ParseResult"
7
+ import * as Schema from "effect/Schema"
8
+ import * as Field from "./Field.ts"
9
+ import * as FormBuilder from "./FormBuilder.ts"
10
+ import { recalculateDirtyFieldsForArray, recalculateDirtySubtree } from "./internal/dirty.ts"
11
+ import { createWeakRegistry, type WeakRegistry } from "./internal/weak-registry.ts"
12
+ import * as Mode from "./Mode.ts"
13
+ import { getNestedValue, isPathOrParentDirty, setNestedValue } from "./Path.ts"
14
+ import * as Validation from "./Validation.ts"
13
15
 
14
16
  export interface FieldAtoms {
15
- readonly valueAtom: Atom.Writable<unknown, unknown>;
16
- readonly initialValueAtom: Atom.Atom<unknown>;
17
- readonly touchedAtom: Atom.Writable<boolean, boolean>;
18
- readonly errorAtom: Atom.Atom<Option.Option<Validation.ErrorEntry>>;
19
- readonly isDirtyAtom: Atom.Atom<boolean>;
17
+ readonly valueAtom: Atom.Writable<unknown, unknown>
18
+ readonly initialValueAtom: Atom.Atom<unknown>
19
+ readonly touchedAtom: Atom.Writable<boolean, boolean>
20
+ readonly errorAtom: Atom.Atom<Option.Option<Validation.ErrorEntry>>
21
+ readonly isDirtyAtom: Atom.Atom<boolean>
22
+ readonly validationAtom: Atom.AtomResultFn<unknown, void, ParseResult.ParseError>
23
+ readonly displayErrorAtom: Atom.Atom<Option.Option<string>>
24
+ readonly shouldValidateAtom: Atom.Atom<boolean>
25
+ readonly triggerValidationAtom: Atom.Atom<void>
20
26
  }
21
27
 
22
- export interface FormAtomsConfig<TFields extends Field.FieldsRecord, R, A, E, SubmitArgs = void> {
23
- readonly runtime: Atom.AtomRuntime<R, any>;
24
- readonly formBuilder: FormBuilder.FormBuilder<TFields, R>;
25
- readonly reactivityKeys?: ReadonlyArray<unknown> | Readonly<Record<string, ReadonlyArray<unknown>>> | undefined;
28
+ export interface PublicFieldAtoms<E,> {
29
+ readonly value: Atom.Atom<Option.Option<E>>
30
+ readonly error: Atom.Atom<Option.Option<string>>
31
+ readonly isDirty: Atom.Atom<boolean>
32
+ readonly isTouched: Atom.Atom<boolean>
33
+ readonly isValidating: Atom.Atom<boolean>
34
+ readonly setValue: Atom.Writable<void, E | ((prev: E) => E)>
35
+ readonly setTouched: Atom.Writable<void, boolean>
36
+ }
37
+
38
+ export type SetValuesArg<TFields extends Field.FieldsRecord,> =
39
+ | Field.EncodedFromFields<TFields>
40
+ | ((prev: Field.EncodedFromFields<TFields>) => Field.EncodedFromFields<TFields>)
41
+
42
+ export interface FormAtomsConfig<TFields extends Field.FieldsRecord, R, A, E, SubmitArgs = void,> {
43
+ readonly runtime: Atom.AtomRuntime<R, any>
44
+ readonly formBuilder: FormBuilder.FormBuilder<TFields, R>
45
+ readonly mode?: Mode.FormMode
46
+ readonly reactivityKeys?: ReadonlyArray<unknown> | Readonly<Record<string, ReadonlyArray<unknown>>> | undefined
26
47
  readonly onSubmit: (
27
48
  args: SubmitArgs,
28
49
  ctx: {
29
- readonly decoded: Field.DecodedFromFields<TFields>;
30
- readonly encoded: Field.EncodedFromFields<TFields>;
31
- readonly get: Atom.FnContext;
32
- },
33
- ) => A | Effect.Effect<A, E, R>;
50
+ readonly decoded: Field.DecodedFromFields<TFields>
51
+ readonly encoded: Field.EncodedFromFields<TFields>
52
+ readonly get: Atom.FnContext
53
+ }
54
+ ) => A | Effect.Effect<A, E, R>
34
55
  }
35
56
 
36
- export type FieldRefs<TFields extends Field.FieldsRecord> = {
57
+ export type FieldRefs<TFields extends Field.FieldsRecord,> = {
37
58
  readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, infer S>
38
59
  ? FormBuilder.FieldRef<Schema.Schema.Encoded<S>>
39
60
  : TFields[K] extends Field.ArrayFieldDef<any, infer S>
40
61
  ? FormBuilder.FieldRef<ReadonlyArray<Schema.Schema.Encoded<S>>>
41
- : never;
42
- };
62
+ : never
63
+ }
43
64
 
44
- export interface FormAtoms<TFields extends Field.FieldsRecord, R, A = void, E = never, SubmitArgs = void> {
65
+ export interface FormAtoms<TFields extends Field.FieldsRecord, R, A = void, E = never, SubmitArgs = void,> {
45
66
  readonly stateAtom: Atom.Writable<
46
67
  Option.Option<FormBuilder.FormState<TFields>>,
47
68
  Option.Option<FormBuilder.FormState<TFields>>
48
- >;
49
- readonly errorsAtom: Atom.Writable<Map<string, Validation.ErrorEntry>, Map<string, Validation.ErrorEntry>>;
50
- readonly rootErrorAtom: Atom.Atom<Option.Option<string>>;
51
- readonly valuesAtom: Atom.Atom<Option.Option<Field.EncodedFromFields<TFields>>>;
52
- readonly dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>;
53
- readonly isDirtyAtom: Atom.Atom<boolean>;
54
- readonly submitCountAtom: Atom.Atom<number>;
55
- readonly lastSubmittedValuesAtom: Atom.Atom<Option.Option<FormBuilder.SubmittedValues<TFields>>>;
56
- readonly changedSinceSubmitFieldsAtom: Atom.Atom<ReadonlySet<string>>;
57
- readonly hasChangedSinceSubmitAtom: Atom.Atom<boolean>;
69
+ >
70
+ readonly errorsAtom: Atom.Writable<Map<string, Validation.ErrorEntry>, Map<string, Validation.ErrorEntry>>
71
+ readonly rootErrorAtom: Atom.Atom<Option.Option<string>>
72
+ readonly valuesAtom: Atom.Atom<Option.Option<Field.EncodedFromFields<TFields>>>
73
+ readonly dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>
74
+ readonly isDirtyAtom: Atom.Atom<boolean>
75
+ readonly submitCountAtom: Atom.Atom<number>
76
+ readonly lastSubmittedValuesAtom: Atom.Atom<Option.Option<FormBuilder.SubmittedValues<TFields>>>
77
+ readonly changedSinceSubmitFieldsAtom: Atom.Atom<ReadonlySet<string>>
78
+ readonly hasChangedSinceSubmitAtom: Atom.Atom<boolean>
58
79
 
59
- readonly submitAtom: Atom.AtomResultFn<SubmitArgs, A, E | ParseResult.ParseError>;
80
+ readonly submitAtom: Atom.AtomResultFn<SubmitArgs, A, E | ParseResult.ParseError>
60
81
 
61
- readonly combinedSchema: Schema.Schema<Field.DecodedFromFields<TFields>, Field.EncodedFromFields<TFields>, R>;
82
+ readonly combinedSchema: Schema.Schema<Field.DecodedFromFields<TFields>, Field.EncodedFromFields<TFields>, R>
62
83
 
63
- readonly fieldRefs: FieldRefs<TFields>;
84
+ readonly fieldRefs: FieldRefs<TFields>
64
85
 
65
- readonly validationAtomsRegistry: WeakRegistry<Atom.AtomResultFn<unknown, void, ParseResult.ParseError>>;
66
- readonly fieldAtomsRegistry: WeakRegistry<FieldAtoms>;
86
+ readonly validationAtomsRegistry: WeakRegistry<Atom.AtomResultFn<unknown, void, ParseResult.ParseError>>
87
+ readonly fieldAtomsRegistry: WeakRegistry<FieldAtoms>
67
88
 
68
89
  readonly getOrCreateValidationAtom: (
69
90
  fieldPath: string,
70
- schema: Schema.Schema.Any,
71
- ) => Atom.AtomResultFn<unknown, void, ParseResult.ParseError>;
91
+ schema: Schema.Schema.Any
92
+ ) => Atom.AtomResultFn<unknown, void, ParseResult.ParseError>
72
93
 
73
- readonly getOrCreateFieldAtoms: (fieldPath: string) => FieldAtoms;
94
+ readonly getOrCreateFieldAtoms: (fieldPath: string, schema: Schema.Schema.Any) => FieldAtoms
74
95
 
75
- readonly resetValidationAtoms: (ctx: { set: <R, W>(atom: Atom.Writable<R, W>, value: W) => void; }) => void;
96
+ readonly resetValidationAtoms: (ctx: { set: <R, W,>(atom: Atom.Writable<R, W>, value: W) => void }) => void
76
97
 
77
- readonly operations: FormOperations<TFields>;
98
+ readonly operations: FormOperations<TFields>
78
99
 
79
- readonly resetAtom: Atom.Writable<void, void>;
80
- readonly revertToLastSubmitAtom: Atom.Writable<void, void>;
81
- readonly setValuesAtom: Atom.Writable<void, Field.EncodedFromFields<TFields>>;
82
- readonly setValue: <S>(field: FormBuilder.FieldRef<S>) => Atom.Writable<void, S | ((prev: S) => S)>;
100
+ readonly resetAtom: Atom.Writable<void, void>
101
+ readonly revertToLastSubmitAtom: Atom.Writable<void, void>
102
+ readonly setValuesAtom: Atom.Writable<void, SetValuesArg<TFields>>
83
103
 
84
- readonly getFieldValue: <S>(field: FormBuilder.FieldRef<S>) => Atom.Atom<Option.Option<S>>;
85
- readonly getFieldIsDirty: (field: FormBuilder.FieldRef<any>) => Atom.Atom<boolean>;
104
+ readonly getFieldAtoms: <S,>(field: FormBuilder.FieldRef<S>) => PublicFieldAtoms<S>
86
105
 
87
106
  /**
88
107
  * Root anchor atom for the form's dependency graph.
@@ -102,110 +121,114 @@ export interface FormAtoms<TFields extends Field.FieldsRecord, R, A = void, E =
102
121
  * }
103
122
  * ```
104
123
  */
105
- readonly mountAtom: Atom.Atom<void>;
124
+ readonly autoSubmitAtom: Atom.Atom<void>
125
+ readonly onBlurSubmitAtom: Atom.Writable<void, void>
106
126
 
107
- readonly keepAliveActiveAtom: Atom.Writable<boolean, boolean>;
127
+ readonly mountAtom: Atom.Atom<void>
128
+
129
+ readonly keepAliveActiveAtom: Atom.Writable<boolean, boolean>
108
130
  }
109
131
 
110
- export interface FormOperations<TFields extends Field.FieldsRecord> {
111
- readonly createInitialState: (defaultValues: Field.EncodedFromFields<TFields>) => FormBuilder.FormState<TFields>;
132
+ export interface FormOperations<TFields extends Field.FieldsRecord,> {
133
+ readonly createInitialState: (defaultValues: Field.EncodedFromFields<TFields>) => FormBuilder.FormState<TFields>
112
134
 
113
- readonly createResetState: (state: FormBuilder.FormState<TFields>) => FormBuilder.FormState<TFields>;
135
+ readonly createResetState: (state: FormBuilder.FormState<TFields>) => FormBuilder.FormState<TFields>
114
136
 
115
- readonly createSubmitState: (state: FormBuilder.FormState<TFields>) => FormBuilder.FormState<TFields>;
137
+ readonly createSubmitState: (state: FormBuilder.FormState<TFields>) => FormBuilder.FormState<TFields>
116
138
 
117
139
  readonly setFieldValue: (
118
140
  state: FormBuilder.FormState<TFields>,
119
141
  fieldPath: string,
120
- value: unknown,
121
- ) => FormBuilder.FormState<TFields>;
142
+ value: unknown
143
+ ) => FormBuilder.FormState<TFields>
122
144
 
123
145
  readonly setFormValues: (
124
146
  state: FormBuilder.FormState<TFields>,
125
- values: Field.EncodedFromFields<TFields>,
126
- ) => FormBuilder.FormState<TFields>;
147
+ values: Field.EncodedFromFields<TFields>
148
+ ) => FormBuilder.FormState<TFields>
127
149
 
128
150
  readonly setFieldTouched: (
129
151
  state: FormBuilder.FormState<TFields>,
130
152
  fieldPath: string,
131
- touched: boolean,
132
- ) => FormBuilder.FormState<TFields>;
153
+ touched: boolean
154
+ ) => FormBuilder.FormState<TFields>
133
155
 
134
156
  readonly appendArrayItem: (
135
157
  state: FormBuilder.FormState<TFields>,
136
158
  arrayPath: string,
137
159
  itemSchema: Schema.Schema.Any,
138
- value?: unknown,
139
- ) => FormBuilder.FormState<TFields>;
160
+ value?: unknown
161
+ ) => FormBuilder.FormState<TFields>
140
162
 
141
163
  readonly removeArrayItem: (
142
164
  state: FormBuilder.FormState<TFields>,
143
165
  arrayPath: string,
144
- index: number,
145
- ) => FormBuilder.FormState<TFields>;
166
+ index: number
167
+ ) => FormBuilder.FormState<TFields>
146
168
 
147
169
  readonly swapArrayItems: (
148
170
  state: FormBuilder.FormState<TFields>,
149
171
  arrayPath: string,
150
172
  indexA: number,
151
- indexB: number,
152
- ) => FormBuilder.FormState<TFields>;
173
+ indexB: number
174
+ ) => FormBuilder.FormState<TFields>
153
175
 
154
176
  readonly moveArrayItem: (
155
177
  state: FormBuilder.FormState<TFields>,
156
178
  arrayPath: string,
157
179
  fromIndex: number,
158
- toIndex: number,
159
- ) => FormBuilder.FormState<TFields>;
180
+ toIndex: number
181
+ ) => FormBuilder.FormState<TFields>
160
182
 
161
- readonly revertToLastSubmit: (state: FormBuilder.FormState<TFields>) => FormBuilder.FormState<TFields>;
183
+ readonly revertToLastSubmit: (state: FormBuilder.FormState<TFields>) => FormBuilder.FormState<TFields>
162
184
  }
163
185
 
164
- export const make = <TFields extends Field.FieldsRecord, R, A, E, SubmitArgs = void>(
165
- config: FormAtomsConfig<TFields, R, A, E, SubmitArgs>,
186
+ export const make = <TFields extends Field.FieldsRecord, R, A, E, SubmitArgs = void,>(
187
+ config: FormAtomsConfig<TFields, R, A, E, SubmitArgs>
166
188
  ): FormAtoms<TFields, R, A, E, SubmitArgs> => {
167
- const { formBuilder, runtime } = config;
168
- const { fields } = formBuilder;
189
+ const { formBuilder, runtime } = config
190
+ const { fields } = formBuilder
191
+ const parsedMode = Mode.parse(config.mode)
169
192
 
170
- const combinedSchema = FormBuilder.buildSchema(formBuilder);
193
+ const combinedSchema = FormBuilder.buildSchema(formBuilder)
171
194
 
172
- const stateAtom = Atom.make(Option.none<FormBuilder.FormState<TFields>>()).pipe(Atom.setIdleTTL(0));
173
- const errorsAtom = Atom.make<Map<string, Validation.ErrorEntry>>(new Map()).pipe(Atom.setIdleTTL(0));
195
+ const stateAtom = Atom.make(Option.none<FormBuilder.FormState<TFields>>()).pipe(Atom.setIdleTTL(0))
196
+ const errorsAtom = Atom.make<Map<string, Validation.ErrorEntry>>(new Map()).pipe(Atom.setIdleTTL(0))
174
197
 
175
198
  const rootErrorAtom = Atom.readable((get) => {
176
- const errors = get(errorsAtom);
177
- const entry = errors.get("");
178
- return entry ? Option.some(entry.message) : Option.none<string>();
179
- }).pipe(Atom.setIdleTTL(0));
199
+ const errors = get(errorsAtom)
200
+ const entry = errors.get("")
201
+ return entry ? Option.some(entry.message) : Option.none<string>()
202
+ }).pipe(Atom.setIdleTTL(0))
180
203
 
181
204
  const valuesAtom = Atom.readable((get) => Option.map(get(stateAtom), (state) => state.values)).pipe(
182
- Atom.setIdleTTL(0),
183
- );
205
+ Atom.setIdleTTL(0)
206
+ )
184
207
 
185
208
  const dirtyFieldsAtom = Atom.readable((get) =>
186
209
  Option.match(get(stateAtom), {
187
210
  onNone: () => new Set<string>(),
188
- onSome: (state) => state.dirtyFields,
211
+ onSome: (state) => state.dirtyFields
189
212
  })
190
- ).pipe(Atom.setIdleTTL(0));
213
+ ).pipe(Atom.setIdleTTL(0))
191
214
 
192
215
  const isDirtyAtom = Atom.readable((get) =>
193
216
  Option.match(get(stateAtom), {
194
217
  onNone: () => false,
195
- onSome: (state) => state.dirtyFields.size > 0,
218
+ onSome: (state) => state.dirtyFields.size > 0
196
219
  })
197
- ).pipe(Atom.setIdleTTL(0));
220
+ ).pipe(Atom.setIdleTTL(0))
198
221
 
199
222
  const submitCountAtom = Atom.readable((get) =>
200
223
  Option.match(get(stateAtom), {
201
224
  onNone: () => 0,
202
- onSome: (state) => state.submitCount,
225
+ onSome: (state) => state.submitCount
203
226
  })
204
- ).pipe(Atom.setIdleTTL(0));
227
+ ).pipe(Atom.setIdleTTL(0))
205
228
 
206
229
  const lastSubmittedValuesAtom = Atom.readable((get) =>
207
230
  Option.flatMap(get(stateAtom), (state) => state.lastSubmittedValues)
208
- ).pipe(Atom.setIdleTTL(0));
231
+ ).pipe(Atom.setIdleTTL(0))
209
232
 
210
233
  const changedSinceSubmitFieldsAtom = Atom.readable((get) =>
211
234
  Option.match(get(stateAtom), {
@@ -213,110 +236,229 @@ export const make = <TFields extends Field.FieldsRecord, R, A, E, SubmitArgs = v
213
236
  onSome: (state) =>
214
237
  Option.match(state.lastSubmittedValues, {
215
238
  onNone: () => new Set<string>(),
216
- onSome: (lastSubmitted) => recalculateDirtySubtree(new Set(), lastSubmitted.encoded, state.values, ""),
217
- }),
239
+ onSome: (lastSubmitted) => recalculateDirtySubtree(new Set(), lastSubmitted.encoded, state.values, "")
240
+ })
218
241
  })
219
- ).pipe(Atom.setIdleTTL(0));
242
+ ).pipe(Atom.setIdleTTL(0))
220
243
 
221
244
  const hasChangedSinceSubmitAtom = Atom.readable((get) =>
222
245
  Option.match(get(stateAtom), {
223
246
  onNone: () => false,
224
247
  onSome: (state) => {
225
- if (Option.isNone(state.lastSubmittedValues)) return false;
226
- if (state.values === state.lastSubmittedValues.value.encoded) return false;
227
- return get(changedSinceSubmitFieldsAtom).size > 0;
228
- },
248
+ if (Option.isNone(state.lastSubmittedValues)) return false
249
+ if (state.values === state.lastSubmittedValues.value.encoded) return false
250
+ return get(changedSinceSubmitFieldsAtom).size > 0
251
+ }
229
252
  })
230
- ).pipe(Atom.setIdleTTL(0));
231
-
232
- const validationAtomsRegistry = createWeakRegistry<Atom.AtomResultFn<unknown, void, ParseResult.ParseError>>();
233
- const fieldAtomsRegistry = createWeakRegistry<FieldAtoms>();
234
- const publicFieldValueRegistry = createWeakRegistry<Atom.Atom<Option.Option<unknown>>>();
253
+ ).pipe(Atom.setIdleTTL(0))
254
+
255
+ const validationAtomsRegistry = createWeakRegistry<Atom.AtomResultFn<unknown, void, ParseResult.ParseError>>()
256
+ const fieldAtomsRegistry = createWeakRegistry<FieldAtoms>()
257
+ const publicFieldAtomsRegistry = createWeakRegistry<PublicFieldAtoms<unknown>>()
258
+ const validationSchemaRegistry = new Map<string, Schema.Schema.Any>()
259
+ const fieldSchemaRegistry = new Map<string, Schema.Schema.Any>()
260
+ const isDirtyAtomsRegistry = createWeakRegistry<Atom.Atom<boolean>>()
261
+
262
+ const fieldSchemasByKey = new Map<string, Schema.Schema.Any>()
263
+ for (const [key, def] of Object.entries(fields)) {
264
+ if (Field.isArrayFieldDef(def)) {
265
+ fieldSchemasByKey.set(key, Schema.Array(def.itemSchema))
266
+ } else if (Field.isFieldDef(def)) {
267
+ fieldSchemasByKey.set(key, def.schema)
268
+ }
269
+ }
235
270
 
236
271
  const getOrCreateValidationAtom = (
237
272
  fieldPath: string,
238
- schema: Schema.Schema.Any,
273
+ schema: Schema.Schema.Any
239
274
  ): Atom.AtomResultFn<unknown, void, ParseResult.ParseError> => {
240
- const existing = validationAtomsRegistry.get(fieldPath);
241
- if (existing) return existing;
275
+ const existing = validationAtomsRegistry.get(fieldPath)
276
+ const existingSchema = validationSchemaRegistry.get(fieldPath)
277
+ if (existing && existingSchema === schema) return existing
242
278
 
243
279
  const validationAtom = runtime
244
280
  .fn<unknown>()((value: unknown) =>
245
281
  pipe(Schema.decodeUnknown(schema)(value) as Effect.Effect<unknown, ParseResult.ParseError, R>, Effect.asVoid)
246
282
  )
247
- .pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<unknown, void, ParseResult.ParseError>;
283
+ .pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<unknown, void, ParseResult.ParseError>
248
284
 
249
- validationAtomsRegistry.set(fieldPath, validationAtom);
250
- return validationAtom;
251
- };
285
+ validationAtomsRegistry.set(fieldPath, validationAtom)
286
+ validationSchemaRegistry.set(fieldPath, schema)
287
+ return validationAtom
288
+ }
252
289
 
253
- const getOrCreateFieldAtoms = (fieldPath: string): FieldAtoms => {
254
- const existing = fieldAtomsRegistry.get(fieldPath);
255
- if (existing) return existing;
290
+ const getOrCreateFieldAtoms = (fieldPath: string, schema: Schema.Schema.Any): FieldAtoms => {
291
+ const existing = fieldAtomsRegistry.get(fieldPath)
292
+ const existingSchema = fieldSchemaRegistry.get(fieldPath)
293
+ if (existing && existingSchema === schema) return existing
256
294
 
257
295
  const valueAtom = Atom.writable(
258
296
  (get) => getNestedValue(Option.getOrThrow(get(stateAtom)).values, fieldPath),
259
297
  (ctx, value) => {
260
- const currentState = Option.getOrThrow(ctx.get(stateAtom));
261
- ctx.set(stateAtom, Option.some(operations.setFieldValue(currentState, fieldPath, value)));
262
- },
263
- ).pipe(Atom.setIdleTTL(0));
298
+ const currentState = Option.getOrThrow(ctx.get(stateAtom))
299
+ ctx.set(stateAtom, Option.some(operations.setFieldValue(currentState, fieldPath, value)))
300
+ }
301
+ ).pipe(Atom.setIdleTTL(0))
264
302
 
265
303
  const initialValueAtom = Atom.readable((get) =>
266
304
  getNestedValue(Option.getOrThrow(get(stateAtom)).initialValues, fieldPath)
267
- ).pipe(Atom.setIdleTTL(0));
305
+ ).pipe(Atom.setIdleTTL(0))
268
306
 
269
307
  const touchedAtom = Atom.writable(
270
308
  (get) => (getNestedValue(Option.getOrThrow(get(stateAtom)).touched, fieldPath) ?? false) as boolean,
271
309
  (ctx, value) => {
272
- const currentState = Option.getOrThrow(ctx.get(stateAtom));
310
+ const currentState = Option.getOrThrow(ctx.get(stateAtom))
273
311
  ctx.set(
274
312
  stateAtom,
275
313
  Option.some({
276
314
  ...currentState,
277
- touched: setNestedValue(currentState.touched, fieldPath, value),
278
- }),
279
- );
280
- },
281
- ).pipe(Atom.setIdleTTL(0));
315
+ touched: setNestedValue(currentState.touched, fieldPath, value)
316
+ })
317
+ )
318
+ }
319
+ ).pipe(Atom.setIdleTTL(0))
282
320
 
283
321
  const errorAtom = Atom.readable((get) => {
284
- const errors = get(errorsAtom);
285
- const entry = errors.get(fieldPath);
286
- return entry ? Option.some(entry) : Option.none<Validation.ErrorEntry>();
287
- }).pipe(Atom.setIdleTTL(0));
322
+ const errors = get(errorsAtom)
323
+ const entry = errors.get(fieldPath)
324
+ return entry ? Option.some(entry) : Option.none<Validation.ErrorEntry>()
325
+ }).pipe(Atom.setIdleTTL(0))
288
326
 
289
- const isDirtyAtom = Atom.readable((get) =>
327
+ const existingIsDirtyAtom = isDirtyAtomsRegistry.get(fieldPath)
328
+ const isDirtyAtom = existingIsDirtyAtom ?? Atom.readable((get) =>
290
329
  isPathOrParentDirty(
291
330
  Option.match(get(stateAtom), {
292
331
  onNone: () => new Set<string>(),
293
- onSome: (state) => state.dirtyFields,
332
+ onSome: (state) => state.dirtyFields
294
333
  }),
295
- fieldPath,
334
+ fieldPath
296
335
  )
297
- ).pipe(Atom.setIdleTTL(0));
336
+ ).pipe(Atom.setIdleTTL(0))
337
+ if (!existingIsDirtyAtom) {
338
+ isDirtyAtomsRegistry.set(fieldPath, isDirtyAtom)
339
+ }
340
+
341
+ const validationAtom = getOrCreateValidationAtom(fieldPath, schema)
342
+
343
+ const shouldValidateAtom = Atom.readable((get) => {
344
+ if (parsedMode.validation === "onChange") return true
345
+ if (parsedMode.validation === "onBlur") return get(touchedAtom)
346
+ return get(submitCountAtom) > 0
347
+ }).pipe(Atom.setIdleTTL(0))
348
+
349
+ const displayErrorAtom = Atom.readable((get) => {
350
+ const validationResult = get(validationAtom)
351
+ const storedError = get(errorAtom)
352
+ const isDirty = get(isDirtyAtom)
353
+ const isTouched = get(touchedAtom)
354
+ const submitCount = get(submitCountAtom)
355
+
356
+ let livePerFieldError: Option.Option<string> = Option.none()
357
+ if (validationResult._tag === "Failure") {
358
+ const parseError = Cause.failureOption(validationResult.cause)
359
+ if (Option.isSome(parseError) && ParseResult.isParseError(parseError.value)) {
360
+ livePerFieldError = Validation.extractFirstError(parseError.value)
361
+ }
362
+ }
363
+
364
+ let validationError: Option.Option<string> = Option.none()
365
+ if (Option.isSome(livePerFieldError)) {
366
+ validationError = livePerFieldError
367
+ } else if (Option.isSome(storedError)) {
368
+ const isValidating = validationResult.waiting
369
+ const shouldHideStoredError = storedError.value.source === "field" &&
370
+ (validationResult._tag === "Success" || isValidating)
371
+ if (!shouldHideStoredError) {
372
+ validationError = Option.some(storedError.value.message)
373
+ }
374
+ }
375
+
376
+ const shouldShowError = parsedMode.validation === "onChange"
377
+ ? isDirty || submitCount > 0
378
+ : parsedMode.validation === "onBlur"
379
+ ? isTouched || submitCount > 0
380
+ : submitCount > 0
381
+
382
+ return shouldShowError ? validationError : Option.none()
383
+ }).pipe(Atom.setIdleTTL(0))
384
+
385
+ const triggerValidationAtom = Atom.readable((get) => {
386
+ let lastValue = get.once(valueAtom)
387
+ let timeout: ReturnType<typeof setTimeout> | undefined
388
+
389
+ const shouldDebounce = parsedMode.validation === "onChange" &&
390
+ parsedMode.debounce !== null && !parsedMode.autoSubmit
391
+ const debounceMs = shouldDebounce ? parsedMode.debounce : null
392
+
393
+ const trigger = (value: unknown) => {
394
+ if (!get.once(shouldValidateAtom)) return
395
+ if (debounceMs !== null && debounceMs > 0) {
396
+ if (timeout !== undefined) clearTimeout(timeout)
397
+ timeout = setTimeout(() => {
398
+ timeout = undefined
399
+ get.set(validationAtom, value)
400
+ }, debounceMs)
401
+ } else {
402
+ get.set(validationAtom, value)
403
+ }
404
+ }
298
405
 
299
- const atoms: FieldAtoms = { valueAtom, initialValueAtom, touchedAtom, errorAtom, isDirtyAtom };
300
- fieldAtomsRegistry.set(fieldPath, atoms);
301
- return atoms;
302
- };
406
+ get.addFinalizer(() => {
407
+ if (timeout !== undefined) clearTimeout(timeout)
408
+ })
409
+
410
+ get.subscribe(valueAtom, (newValue) => {
411
+ if (newValue === lastValue) return
412
+ lastValue = newValue
413
+ trigger(newValue)
414
+ })
415
+
416
+ if (parsedMode.validation === "onBlur") {
417
+ get.subscribe(touchedAtom, (isTouched) => {
418
+ if (isTouched) {
419
+ const currentValue = get.once(valueAtom)
420
+ get.set(validationAtom, currentValue)
421
+ }
422
+ })
423
+ }
424
+ }).pipe(Atom.setIdleTTL(0))
425
+
426
+ const atoms: FieldAtoms = {
427
+ valueAtom,
428
+ initialValueAtom,
429
+ touchedAtom,
430
+ errorAtom,
431
+ isDirtyAtom,
432
+ validationAtom,
433
+ displayErrorAtom,
434
+ shouldValidateAtom,
435
+ triggerValidationAtom
436
+ }
437
+ fieldAtomsRegistry.set(fieldPath, atoms)
438
+ fieldSchemaRegistry.set(fieldPath, schema)
439
+ return atoms
440
+ }
303
441
 
304
- const resetValidationAtoms = (ctx: { set: <R, W>(atom: Atom.Writable<R, W>, value: W) => void; }) => {
442
+ const resetValidationAtoms = (ctx: { set: <R, W,>(atom: Atom.Writable<R, W>, value: W) => void }) => {
305
443
  for (const validationAtom of validationAtomsRegistry.values()) {
306
- ctx.set(validationAtom, Atom.Reset);
444
+ ctx.set(validationAtom, Atom.Reset)
307
445
  }
308
- validationAtomsRegistry.clear();
309
- fieldAtomsRegistry.clear();
310
- };
446
+ validationAtomsRegistry.clear()
447
+ fieldAtomsRegistry.clear()
448
+ publicFieldAtomsRegistry.clear()
449
+ validationSchemaRegistry.clear()
450
+ fieldSchemaRegistry.clear()
451
+ isDirtyAtomsRegistry.clear()
452
+ }
311
453
 
312
454
  const submitAtom = runtime
313
455
  .fn<SubmitArgs>()(
314
456
  (args, get) =>
315
457
  Effect.gen(function*() {
316
- const state = get(stateAtom);
317
- if (Option.isNone(state)) return yield* Effect.die("Form not initialized");
318
- const values = state.value.values;
319
- get.set(errorsAtom, new Map());
458
+ const state = get(stateAtom)
459
+ if (Option.isNone(state)) return yield* Effect.die("Form not initialized")
460
+ const values = state.value.values
461
+ get.set(errorsAtom, new Map())
320
462
  const decoded = yield* pipe(
321
463
  Schema.decodeUnknown(combinedSchema, { errors: "all" })(values) as Effect.Effect<
322
464
  Field.DecodedFromFields<TFields>,
@@ -325,106 +467,106 @@ export const make = <TFields extends Field.FieldsRecord, R, A, E, SubmitArgs = v
325
467
  >,
326
468
  Effect.tapError((parseError) =>
327
469
  Effect.sync(() => {
328
- const routedErrors = Validation.routeErrorsWithSource(parseError);
329
- get.set(errorsAtom, routedErrors);
330
- get.set(stateAtom, Option.some(operations.createSubmitState(state.value)));
470
+ const routedErrors = Validation.routeErrorsWithSource(parseError)
471
+ get.set(errorsAtom, routedErrors)
472
+ get.set(stateAtom, Option.some(operations.createSubmitState(state.value)))
331
473
  })
332
- ),
333
- );
334
- const submitState = operations.createSubmitState(state.value);
474
+ )
475
+ )
476
+ const submitState = operations.createSubmitState(state.value)
335
477
  get.set(
336
478
  stateAtom,
337
479
  Option.some({
338
480
  ...submitState,
339
- lastSubmittedValues: Option.some({ encoded: values, decoded }),
340
- }),
341
- );
342
- const result = config.onSubmit(args, { decoded, encoded: values, get });
481
+ lastSubmittedValues: Option.some({ encoded: values, decoded })
482
+ })
483
+ )
484
+ const result = config.onSubmit(args, { decoded, encoded: values, get })
343
485
  if (Effect.isEffect(result)) {
344
- return yield* result as Effect.Effect<A, E, R>;
486
+ return yield* result as Effect.Effect<A, E, R>
345
487
  }
346
- return result as A;
488
+ return result as A
347
489
  }),
348
- config.reactivityKeys ? { reactivityKeys: config.reactivityKeys } : undefined,
490
+ config.reactivityKeys ? { reactivityKeys: config.reactivityKeys } : undefined
349
491
  )
350
- .pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<SubmitArgs, A, E | ParseResult.ParseError>;
492
+ .pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<SubmitArgs, A, E | ParseResult.ParseError>
351
493
 
352
494
  const fieldRefs = Object.fromEntries(
353
- Object.keys(fields).map((key) => [key, FormBuilder.makeFieldRef(key)]),
354
- ) as FieldRefs<TFields>;
495
+ Object.keys(fields).map((key) => [key, FormBuilder.makeFieldRef(key)])
496
+ ) as FieldRefs<TFields>
355
497
 
356
498
  const operations: FormOperations<TFields> = {
357
499
  createInitialState: (defaultValues) => ({
358
500
  values: defaultValues,
359
501
  initialValues: defaultValues,
360
502
  lastSubmittedValues: Option.none(),
361
- touched: Field.createTouchedRecord(fields, false) as { readonly [K in keyof TFields]: boolean; },
503
+ touched: Field.createTouchedRecord(fields, false) as { readonly [K in keyof TFields]: boolean },
362
504
  submitCount: 0,
363
- dirtyFields: new Set(),
505
+ dirtyFields: new Set()
364
506
  }),
365
507
 
366
508
  createResetState: (state) => ({
367
509
  values: state.initialValues,
368
510
  initialValues: state.initialValues,
369
511
  lastSubmittedValues: Option.none(),
370
- touched: Field.createTouchedRecord(fields, false) as { readonly [K in keyof TFields]: boolean; },
512
+ touched: Field.createTouchedRecord(fields, false) as { readonly [K in keyof TFields]: boolean },
371
513
  submitCount: 0,
372
- dirtyFields: new Set(),
514
+ dirtyFields: new Set()
373
515
  }),
374
516
 
375
517
  createSubmitState: (state) => ({
376
518
  ...state,
377
- touched: Field.createTouchedRecord(fields, true) as { readonly [K in keyof TFields]: boolean; },
378
- submitCount: state.submitCount + 1,
519
+ touched: Field.createTouchedRecord(fields, true) as { readonly [K in keyof TFields]: boolean },
520
+ submitCount: state.submitCount + 1
379
521
  }),
380
522
 
381
523
  setFieldValue: (state, fieldPath, value) => {
382
- const newValues = setNestedValue(state.values, fieldPath, value);
383
- const newDirtyFields = recalculateDirtySubtree(state.dirtyFields, state.initialValues, newValues, fieldPath);
524
+ const newValues = setNestedValue(state.values, fieldPath, value)
525
+ const newDirtyFields = recalculateDirtySubtree(state.dirtyFields, state.initialValues, newValues, fieldPath)
384
526
  return {
385
527
  ...state,
386
528
  values: newValues as Field.EncodedFromFields<TFields>,
387
- dirtyFields: newDirtyFields,
388
- };
529
+ dirtyFields: newDirtyFields
530
+ }
389
531
  },
390
532
 
391
533
  setFormValues: (state, values) => {
392
- const newDirtyFields = recalculateDirtySubtree(state.dirtyFields, state.initialValues, values, "");
534
+ const newDirtyFields = recalculateDirtySubtree(state.dirtyFields, state.initialValues, values, "")
393
535
  return {
394
536
  ...state,
395
537
  values,
396
- dirtyFields: newDirtyFields,
397
- };
538
+ dirtyFields: newDirtyFields
539
+ }
398
540
  },
399
541
 
400
542
  setFieldTouched: (state, fieldPath, touched) => ({
401
543
  ...state,
402
- touched: setNestedValue(state.touched, fieldPath, touched) as { readonly [K in keyof TFields]: boolean; },
544
+ touched: setNestedValue(state.touched, fieldPath, touched) as { readonly [K in keyof TFields]: boolean }
403
545
  }),
404
546
 
405
547
  appendArrayItem: (state, arrayPath, itemSchema, value) => {
406
- const newItem = value ?? Field.getDefaultFromSchema(itemSchema);
407
- const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>;
408
- const newItems = [...currentItems, newItem];
548
+ const newItem = value ?? Field.getDefaultFromSchema(itemSchema)
549
+ const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>
550
+ const newItems = [...currentItems, newItem]
409
551
  return {
410
552
  ...state,
411
553
  values: setNestedValue(state.values, arrayPath, newItems) as Field.EncodedFromFields<TFields>,
412
- dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
413
- };
554
+ dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems)
555
+ }
414
556
  },
415
557
 
416
558
  removeArrayItem: (state, arrayPath, index) => {
417
- const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>;
418
- const newItems = currentItems.filter((_, i) => i !== index);
559
+ const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>
560
+ const newItems = currentItems.filter((_, i) => i !== index)
419
561
  return {
420
562
  ...state,
421
563
  values: setNestedValue(state.values, arrayPath, newItems) as Field.EncodedFromFields<TFields>,
422
- dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
423
- };
564
+ dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems)
565
+ }
424
566
  },
425
567
 
426
568
  swapArrayItems: (state, arrayPath, indexA, indexB) => {
427
- const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>;
569
+ const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>
428
570
  if (
429
571
  indexA < 0 ||
430
572
  indexA >= currentItems.length ||
@@ -432,21 +574,21 @@ export const make = <TFields extends Field.FieldsRecord, R, A, E, SubmitArgs = v
432
574
  indexB >= currentItems.length ||
433
575
  indexA === indexB
434
576
  ) {
435
- return state;
577
+ return state
436
578
  }
437
- const newItems = [...currentItems];
438
- const temp = newItems[indexA];
439
- newItems[indexA] = newItems[indexB];
440
- newItems[indexB] = temp;
579
+ const newItems = [...currentItems]
580
+ const temp = newItems[indexA]
581
+ newItems[indexA] = newItems[indexB]
582
+ newItems[indexB] = temp
441
583
  return {
442
584
  ...state,
443
585
  values: setNestedValue(state.values, arrayPath, newItems) as Field.EncodedFromFields<TFields>,
444
- dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
445
- };
586
+ dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems)
587
+ }
446
588
  },
447
589
 
448
590
  moveArrayItem: (state, arrayPath, fromIndex, toIndex) => {
449
- const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>;
591
+ const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>
450
592
  if (
451
593
  fromIndex < 0 ||
452
594
  fromIndex >= currentItems.length ||
@@ -454,116 +596,257 @@ export const make = <TFields extends Field.FieldsRecord, R, A, E, SubmitArgs = v
454
596
  toIndex > currentItems.length ||
455
597
  fromIndex === toIndex
456
598
  ) {
457
- return state;
599
+ return state
458
600
  }
459
- const newItems = [...currentItems];
460
- const [item] = newItems.splice(fromIndex, 1);
461
- newItems.splice(toIndex, 0, item);
601
+ const newItems = [...currentItems]
602
+ const [item] = newItems.splice(fromIndex, 1)
603
+ newItems.splice(toIndex, 0, item)
462
604
  return {
463
605
  ...state,
464
606
  values: setNestedValue(state.values, arrayPath, newItems) as Field.EncodedFromFields<TFields>,
465
- dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
466
- };
607
+ dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems)
608
+ }
467
609
  },
468
610
 
469
611
  revertToLastSubmit: (state) => {
470
612
  if (Option.isNone(state.lastSubmittedValues)) {
471
- return state;
613
+ return state
472
614
  }
473
615
 
474
- const lastEncoded = state.lastSubmittedValues.value.encoded;
616
+ const lastEncoded = state.lastSubmittedValues.value.encoded
475
617
  if (state.values === lastEncoded) {
476
- return state;
618
+ return state
477
619
  }
478
620
 
479
- const newDirtyFields = recalculateDirtySubtree(state.dirtyFields, state.initialValues, lastEncoded, "");
621
+ const newDirtyFields = recalculateDirtySubtree(state.dirtyFields, state.initialValues, lastEncoded, "")
480
622
 
481
623
  return {
482
624
  ...state,
483
625
  values: lastEncoded,
484
- dirtyFields: newDirtyFields,
485
- };
486
- },
487
- };
626
+ dirtyFields: newDirtyFields
627
+ }
628
+ }
629
+ }
488
630
 
489
631
  const resetAtom = Atom.fnSync<void>()(
490
632
  (_: void, get) => {
491
- const state = get(stateAtom);
492
- if (Option.isNone(state)) return;
493
- get.set(stateAtom, Option.some(operations.createResetState(state.value)));
494
- get.set(errorsAtom, new Map());
495
- resetValidationAtoms(get);
496
- get.set(submitAtom, Atom.Reset);
633
+ const state = get(stateAtom)
634
+ if (Option.isNone(state)) return
635
+ get.set(stateAtom, Option.some(operations.createResetState(state.value)))
636
+ get.set(errorsAtom, new Map())
637
+ resetValidationAtoms(get)
638
+ get.set(submitAtom, Atom.Reset)
497
639
  },
498
- { initialValue: undefined as void },
499
- ).pipe(Atom.setIdleTTL(0));
640
+ { initialValue: undefined as void }
641
+ ).pipe(Atom.setIdleTTL(0))
500
642
 
501
643
  const revertToLastSubmitAtom = Atom.fnSync<void>()(
502
644
  (_: void, get) => {
503
- const state = get(stateAtom);
504
- if (Option.isNone(state)) return;
505
- get.set(stateAtom, Option.some(operations.revertToLastSubmit(state.value)));
506
- get.set(errorsAtom, new Map());
645
+ const state = get(stateAtom)
646
+ if (Option.isNone(state)) return
647
+ get.set(stateAtom, Option.some(operations.revertToLastSubmit(state.value)))
648
+ get.set(errorsAtom, new Map())
507
649
  },
508
- { initialValue: undefined as void },
509
- ).pipe(Atom.setIdleTTL(0));
510
-
511
- const setValuesAtom = Atom.fnSync<Field.EncodedFromFields<TFields>>()(
512
- (_values, get) => {
513
- const state = get(stateAtom);
514
- if (Option.isNone(state)) return;
515
- get.set(stateAtom, Option.some(operations.setFormValues(state.value, _values)));
516
- get.set(errorsAtom, new Map());
650
+ { initialValue: undefined as void }
651
+ ).pipe(Atom.setIdleTTL(0))
652
+
653
+ const setValuesAtom = Atom.fnSync<SetValuesArg<TFields>>()(
654
+ (update, get) => {
655
+ const state = get(stateAtom)
656
+ if (Option.isNone(state)) return
657
+ const values = typeof update === "function"
658
+ ? (update as (prev: Field.EncodedFromFields<TFields>) => Field.EncodedFromFields<TFields>)(state.value.values)
659
+ : update
660
+ get.set(stateAtom, Option.some(operations.setFormValues(state.value, values)))
661
+ get.set(errorsAtom, new Map())
517
662
  },
518
- { initialValue: undefined as void },
519
- ).pipe(Atom.setIdleTTL(0));
663
+ { initialValue: undefined as void }
664
+ ).pipe(Atom.setIdleTTL(0))
520
665
 
521
- const setValueAtomsRegistry = createWeakRegistry<Atom.Writable<void, any>>();
666
+ const setValueAtomsRegistry = createWeakRegistry<Atom.Writable<void, any>>()
522
667
 
523
- const setValue = <S>(field: FormBuilder.FieldRef<S>): Atom.Writable<void, S | ((prev: S) => S)> => {
524
- const cached = setValueAtomsRegistry.get(field.key);
525
- if (cached) return cached;
668
+ const setValue = <S,>(field: FormBuilder.FieldRef<S>): Atom.Writable<void, S | ((prev: S) => S)> => {
669
+ const cached = setValueAtomsRegistry.get(field.key)
670
+ if (cached) return cached
526
671
 
527
672
  const atom = Atom.fnSync<S | ((prev: S) => S)>()(
528
673
  (update, get) => {
529
- const state = get(stateAtom);
530
- if (Option.isNone(state)) return;
674
+ const state = get(stateAtom)
675
+ if (Option.isNone(state)) return
531
676
 
532
- const currentValue = getNestedValue(state.value.values, field.key) as S;
533
- const newValue = typeof update === "function" ? (update as (prev: S) => S)(currentValue) : update;
677
+ const currentValue = getNestedValue(state.value.values, field.key) as S
678
+ const newValue = typeof update === "function" ? (update as (prev: S) => S)(currentValue) : update
534
679
 
535
- get.set(stateAtom, Option.some(operations.setFieldValue(state.value, field.key, newValue)));
680
+ get.set(stateAtom, Option.some(operations.setFieldValue(state.value, field.key, newValue)))
536
681
  // Don't clear errors - display logic handles showing/hiding based on source + validation state
537
682
  },
538
- { initialValue: undefined as void },
539
- ).pipe(Atom.setIdleTTL(0));
683
+ { initialValue: undefined as void }
684
+ ).pipe(Atom.setIdleTTL(0))
685
+
686
+ setValueAtomsRegistry.set(field.key, atom)
687
+ return atom
688
+ }
689
+
690
+ const getFieldIsDirty = (field: FormBuilder.FieldRef<any>): Atom.Atom<boolean> => {
691
+ const cached = fieldAtomsRegistry.get(field.key)
692
+ if (cached) return cached.isDirtyAtom
693
+
694
+ const existing = isDirtyAtomsRegistry.get(field.key)
695
+ if (existing) return existing
696
+
697
+ const atom = Atom.readable((get) =>
698
+ isPathOrParentDirty(
699
+ Option.match(get(stateAtom), {
700
+ onNone: () => new Set<string>(),
701
+ onSome: (state) => state.dirtyFields
702
+ }),
703
+ field.key
704
+ )
705
+ ).pipe(Atom.setIdleTTL(0))
706
+
707
+ isDirtyAtomsRegistry.set(field.key, atom)
708
+ return atom
709
+ }
540
710
 
541
- setValueAtomsRegistry.set(field.key, atom);
542
- return atom;
543
- };
711
+ const getFieldAtoms = <S,>(field: FormBuilder.FieldRef<S>): PublicFieldAtoms<S> => {
712
+ const cached = publicFieldAtomsRegistry.get(field.key)
713
+ if (cached) return cached as PublicFieldAtoms<S>
544
714
 
545
- const getFieldValue = <S>(field: FormBuilder.FieldRef<S>): Atom.Atom<Option.Option<S>> => {
546
- const existing = publicFieldValueRegistry.get(field.key);
547
- if (existing) return existing as Atom.Atom<Option.Option<S>>;
715
+ const schema = fieldSchemasByKey.get(field.key)
716
+ if (!schema) throw new Error(`No schema found for field "${field.key}"`)
548
717
 
549
- const safeAtom = Atom.readable((get) =>
718
+ const internal = getOrCreateFieldAtoms(field.key, schema)
719
+
720
+ const value = Atom.readable((get) =>
550
721
  Option.map(get(stateAtom), (state) => getNestedValue(state.values, field.key) as S)
551
- ).pipe(Atom.setIdleTTL(0));
722
+ ).pipe(Atom.setIdleTTL(0))
723
+
724
+ const error = Atom.readable((get) =>
725
+ Option.match(get(stateAtom), {
726
+ onNone: () => Option.none<string>(),
727
+ onSome: () => get(internal.displayErrorAtom)
728
+ })
729
+ ).pipe(Atom.setIdleTTL(0))
730
+
731
+ const isDirty = getFieldIsDirty(field)
732
+
733
+ const isTouched = Atom.readable((get) =>
734
+ Option.match(get(stateAtom), {
735
+ onNone: () => false,
736
+ onSome: (state) => (getNestedValue(state.touched, field.key) ?? false) as boolean
737
+ })
738
+ ).pipe(Atom.setIdleTTL(0))
739
+
740
+ const isValidating = Atom.readable((get) => get(internal.validationAtom).waiting).pipe(Atom.setIdleTTL(0))
552
741
 
553
- publicFieldValueRegistry.set(field.key, safeAtom);
554
- return safeAtom;
555
- };
742
+ const setValueAtom = setValue(field)
556
743
 
557
- const getFieldIsDirty = (field: FormBuilder.FieldRef<any>): Atom.Atom<boolean> =>
558
- getOrCreateFieldAtoms(field.key).isDirtyAtom;
744
+ const setTouchedAtom = Atom.fnSync<boolean>()(
745
+ (touched, get) => {
746
+ const state = get(stateAtom)
747
+ if (Option.isNone(state)) return
748
+ get.set(stateAtom, Option.some(operations.setFieldTouched(state.value, field.key, touched)))
749
+ },
750
+ { initialValue: undefined as void }
751
+ ).pipe(Atom.setIdleTTL(0))
752
+
753
+ const bundle: PublicFieldAtoms<S> = {
754
+ value,
755
+ error,
756
+ isDirty,
757
+ isTouched,
758
+ isValidating,
759
+ setValue: setValueAtom,
760
+ setTouched: setTouchedAtom
761
+ }
762
+ publicFieldAtomsRegistry.set(field.key, bundle as PublicFieldAtoms<unknown>)
763
+ return bundle
764
+ }
559
765
 
560
766
  const mountAtom = Atom.readable((get) => {
561
- get(stateAtom);
562
- get(errorsAtom);
563
- get(submitAtom);
564
- }).pipe(Atom.setIdleTTL(0));
767
+ get(stateAtom)
768
+ get(errorsAtom)
769
+ get(submitAtom)
770
+ }).pipe(Atom.setIdleTTL(0))
771
+
772
+ const keepAliveActiveAtom = Atom.make(false).pipe(Atom.setIdleTTL(0))
773
+
774
+ const autoSubmitAtom: Atom.Atom<void> = parsedMode.autoSubmit && parsedMode.validation === "onChange"
775
+ ? Atom.readable((get) => {
776
+ const initialState = get.once(stateAtom)
777
+ let lastValues: unknown = Option.isSome(initialState)
778
+ ? initialState.value.values
779
+ : null
780
+ let pendingChanges = false
781
+ let wasSubmitting = false
782
+ let timeout: ReturnType<typeof setTimeout> | undefined
783
+
784
+ const debounceMs = parsedMode.debounce
785
+
786
+ const triggerSubmit = () => {
787
+ if (get.once(submitAtom).waiting) {
788
+ pendingChanges = true
789
+ return
790
+ }
791
+ get.set(submitAtom as Atom.Writable<any, any>, undefined)
792
+ }
565
793
 
566
- const keepAliveActiveAtom = Atom.make(false).pipe(Atom.setIdleTTL(0));
794
+ const debouncedSubmit = () => {
795
+ if (debounceMs !== null && debounceMs > 0) {
796
+ if (timeout !== undefined) clearTimeout(timeout)
797
+ timeout = setTimeout(() => {
798
+ timeout = undefined
799
+ triggerSubmit()
800
+ }, debounceMs)
801
+ } else {
802
+ triggerSubmit()
803
+ }
804
+ }
805
+
806
+ get.addFinalizer(() => {
807
+ if (timeout !== undefined) clearTimeout(timeout)
808
+ })
809
+
810
+ get.subscribe(stateAtom, () => {
811
+ const state = get.once(stateAtom)
812
+ if (Option.isNone(state)) return
813
+ const currentValues = state.value.values
814
+ if (currentValues === lastValues) return
815
+ lastValues = currentValues
816
+
817
+ const submitResult = get.once(submitAtom)
818
+ if (submitResult.waiting) {
819
+ pendingChanges = true
820
+ } else {
821
+ debouncedSubmit()
822
+ }
823
+ })
824
+
825
+ get.subscribe(submitAtom, () => {
826
+ const result = get.once(submitAtom)
827
+ const isSubmitting = result.waiting
828
+ if (wasSubmitting && !isSubmitting) {
829
+ if (pendingChanges) {
830
+ pendingChanges = false
831
+ debouncedSubmit()
832
+ }
833
+ }
834
+ wasSubmitting = isSubmitting
835
+ })
836
+ }).pipe(Atom.setIdleTTL(0))
837
+ : Atom.readable(() => {}).pipe(Atom.setIdleTTL(0))
838
+
839
+ const onBlurSubmitAtom: Atom.Writable<void, void> = parsedMode.autoSubmit && parsedMode.validation === "onBlur"
840
+ ? Atom.fnSync<void>()((_: void, get) => {
841
+ if (get(submitAtom).waiting) return
842
+ const stateOption = get(stateAtom)
843
+ if (Option.isNone(stateOption)) return
844
+ const { lastSubmittedValues, values } = stateOption.value
845
+ if (Option.isSome(lastSubmittedValues) && values === lastSubmittedValues.value.encoded) return
846
+ get.set(submitAtom as Atom.Writable<any, any>, undefined)
847
+ }, { initialValue: undefined as void }).pipe(Atom.setIdleTTL(0))
848
+ : Atom.fnSync<void>()((_: void) => {}, { initialValue: undefined as void })
849
+ .pipe(Atom.setIdleTTL(0))
567
850
 
568
851
  return {
569
852
  stateAtom,
@@ -588,10 +871,10 @@ export const make = <TFields extends Field.FieldsRecord, R, A, E, SubmitArgs = v
588
871
  resetAtom,
589
872
  revertToLastSubmitAtom,
590
873
  setValuesAtom,
591
- setValue,
592
- getFieldValue,
593
- getFieldIsDirty,
874
+ getFieldAtoms,
875
+ autoSubmitAtom,
876
+ onBlurSubmitAtom,
594
877
  mountAtom,
595
- keepAliveActiveAtom,
596
- } as FormAtoms<TFields, R, A, E, SubmitArgs>;
597
- };
878
+ keepAliveActiveAtom
879
+ } as FormAtoms<TFields, R, A, E, SubmitArgs>
880
+ }