@voila.dev/effect-form 0.27.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/Field.d.ts +30 -0
  4. package/dist/Field.d.ts.map +1 -0
  5. package/dist/Field.js +86 -0
  6. package/dist/Field.js.map +1 -0
  7. package/dist/FieldState.d.ts +21 -0
  8. package/dist/FieldState.d.ts.map +1 -0
  9. package/dist/FieldState.js +2 -0
  10. package/dist/FieldState.js.map +1 -0
  11. package/dist/FormAtoms.d.ts +108 -0
  12. package/dist/FormAtoms.d.ts.map +1 -0
  13. package/dist/FormAtoms.js +620 -0
  14. package/dist/FormAtoms.js.map +1 -0
  15. package/dist/FormBuilder.d.ts +74 -0
  16. package/dist/FormBuilder.d.ts.map +1 -0
  17. package/dist/FormBuilder.js +86 -0
  18. package/dist/FormBuilder.js.map +1 -0
  19. package/dist/Mode.d.ts +34 -0
  20. package/dist/Mode.d.ts.map +1 -0
  21. package/dist/Mode.js +19 -0
  22. package/dist/Mode.js.map +1 -0
  23. package/dist/Path.d.ts +6 -0
  24. package/dist/Path.d.ts.map +1 -0
  25. package/dist/Path.js +69 -0
  26. package/dist/Path.js.map +1 -0
  27. package/dist/Validation.d.ts +11 -0
  28. package/dist/Validation.d.ts.map +1 -0
  29. package/dist/Validation.js +128 -0
  30. package/dist/Validation.js.map +1 -0
  31. package/dist/index.d.ts +8 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +8 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/internal/dirty.d.ts +3 -0
  36. package/dist/internal/dirty.d.ts.map +1 -0
  37. package/dist/internal/dirty.js +89 -0
  38. package/dist/internal/dirty.js.map +1 -0
  39. package/package.json +43 -0
  40. package/src/Field.ts +149 -0
  41. package/src/FieldState.ts +23 -0
  42. package/src/FormAtoms.ts +1202 -0
  43. package/src/FormBuilder.ts +235 -0
  44. package/src/Mode.ts +63 -0
  45. package/src/Path.ts +76 -0
  46. package/src/Validation.ts +167 -0
  47. package/src/index.ts +13 -0
  48. package/src/internal/dirty.ts +113 -0
@@ -0,0 +1,620 @@
1
+ import * as Duration from "effect/Duration";
2
+ import * as Effect from "effect/Effect";
3
+ import { pipe } from "effect/Function";
4
+ import * as Option from "effect/Option";
5
+ import * as Schema from "effect/Schema";
6
+ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
7
+ import * as Atom from "effect/unstable/reactivity/Atom";
8
+ import * as Field from "./Field.js";
9
+ import * as FormBuilder from "./FormBuilder.js";
10
+ import { recalculateDirtyFieldsForArray, recalculateDirtySubtree, } from "./internal/dirty.js";
11
+ import * as Mode from "./Mode.js";
12
+ import { getNestedValue, isPathOrParentDirty, setNestedValue } from "./Path.js";
13
+ import * as Validation from "./Validation.js";
14
+ const getStateOrThrow = (state, fieldPath) => {
15
+ if (Option.isNone(state)) {
16
+ throw new Error(`Field "${fieldPath}" was read before the form was initialized. ` +
17
+ "Form state does not exist until initialization: render your fields inside " +
18
+ "<form.Initialize defaultValues={...}> (React/Solid), or set the form state before reading field atoms. " +
19
+ `See the "Basic Form Setup" section of the README.`);
20
+ }
21
+ return state.value;
22
+ };
23
+ export const make = (config) => {
24
+ const { formBuilder, runtime } = config;
25
+ const { fields } = formBuilder;
26
+ const parsedMode = Mode.parse(config.mode);
27
+ // A zero (or absent) debounce means "fire synchronously", so only a strictly
28
+ // positive duration goes through `Atom.debounce`.
29
+ const positiveDebounce = (input) => input !== null && Duration.toMillis(Duration.fromInputUnsafe(input)) > 0
30
+ ? input
31
+ : null;
32
+ const validationDebounce = parsedMode.validation === "onChange" && !parsedMode.autoSubmit
33
+ ? positiveDebounce(parsedMode.debounce)
34
+ : null;
35
+ const autoSubmitDebounce = positiveDebounce(parsedMode.debounce);
36
+ const combinedSchema = FormBuilder.buildSchema(formBuilder);
37
+ const stateAtom = Atom.make(Option.none()).pipe(Atom.setIdleTTL(0));
38
+ const errorsAtom = Atom.make(new Map()).pipe(Atom.setIdleTTL(0));
39
+ const rootErrorAtom = Atom.readable((get) => {
40
+ const errors = get(errorsAtom);
41
+ const entry = errors.get("");
42
+ return entry ? Option.some(entry.message) : Option.none();
43
+ }).pipe(Atom.setIdleTTL(0));
44
+ const valuesAtom = Atom.readable((get) => Option.map(get(stateAtom), (state) => state.values)).pipe(Atom.setIdleTTL(0));
45
+ const dirtyFieldsAtom = Atom.readable((get) => Option.match(get(stateAtom), {
46
+ onNone: () => new Set(),
47
+ onSome: (state) => state.dirtyFields,
48
+ })).pipe(Atom.setIdleTTL(0));
49
+ const isDirtyAtom = Atom.readable((get) => Option.match(get(stateAtom), {
50
+ onNone: () => false,
51
+ onSome: (state) => state.dirtyFields.size > 0,
52
+ })).pipe(Atom.setIdleTTL(0));
53
+ const submitCountAtom = Atom.readable((get) => Option.match(get(stateAtom), {
54
+ onNone: () => 0,
55
+ onSome: (state) => state.submitCount,
56
+ })).pipe(Atom.setIdleTTL(0));
57
+ const validationCountAtom = Atom.readable((get) => Option.match(get(stateAtom), {
58
+ onNone: () => 0,
59
+ onSome: (state) => state.validationCount,
60
+ })).pipe(Atom.setIdleTTL(0));
61
+ const lastSubmittedValuesAtom = Atom.readable((get) => Option.flatMap(get(stateAtom), (state) => state.lastSubmittedValues)).pipe(Atom.setIdleTTL(0));
62
+ const changedSinceSubmitFieldsAtom = Atom.readable((get) => Option.match(get(stateAtom), {
63
+ onNone: () => new Set(),
64
+ onSome: (state) => Option.match(state.lastSubmittedValues, {
65
+ onNone: () => new Set(),
66
+ onSome: (lastSubmitted) => recalculateDirtySubtree(new Set(), lastSubmitted.encoded, state.values, ""),
67
+ }),
68
+ })).pipe(Atom.setIdleTTL(0));
69
+ const hasChangedSinceSubmitAtom = Atom.readable((get) => Option.match(get(stateAtom), {
70
+ onNone: () => false,
71
+ onSome: (state) => {
72
+ if (Option.isNone(state.lastSubmittedValues))
73
+ return false;
74
+ if (state.values === state.lastSubmittedValues.value.encoded)
75
+ return false;
76
+ return get(changedSinceSubmitFieldsAtom).size > 0;
77
+ },
78
+ })).pipe(Atom.setIdleTTL(0));
79
+ const fieldSchemasByKey = new Map();
80
+ for (const [key, def] of Object.entries(fields)) {
81
+ if (Field.isArrayFieldDef(def)) {
82
+ fieldSchemasByKey.set(key, Schema.Array(def.itemSchema));
83
+ }
84
+ else if (Field.isFieldDef(def)) {
85
+ fieldSchemasByKey.set(key, def.schema);
86
+ }
87
+ }
88
+ // `Atom.family` does not expose iteration, but `resetValidationAtoms` must visit
89
+ // every created entry. These maps record the latest key per path (paths are
90
+ // bounded by the form's field/array-item structure, so no GC concern) and double
91
+ // as the schema-identity record: `family(key)` with a recorded key returns the
92
+ // memoized entry, or harmlessly recreates a fresh one if it was collected.
93
+ const validationKeys = new Map();
94
+ const fieldAtomsKeys = new Map();
95
+ const validationAtomFamily = Atom.family(({ schema, }) => runtime
96
+ .fn()((value) => pipe(Schema.decodeUnknownEffect(schema)(value), Effect.asVoid))
97
+ .pipe(Atom.setIdleTTL(0)));
98
+ const getOrCreateValidationAtom = (fieldPath, schema) => {
99
+ const key = { path: fieldPath, schema };
100
+ validationKeys.set(fieldPath, key);
101
+ return validationAtomFamily(key);
102
+ };
103
+ const isDirtyAtomFamily = Atom.family((fieldPath) => Atom.readable((get) => isPathOrParentDirty(Option.match(get(stateAtom), {
104
+ onNone: () => new Set(),
105
+ onSome: (state) => state.dirtyFields,
106
+ }), fieldPath)).pipe(Atom.setIdleTTL(0)));
107
+ const fieldAtomsFamily = Atom.family(({ path: fieldPath, schema }) => {
108
+ const valueAtom = Atom.writable((get) => getNestedValue(getStateOrThrow(get(stateAtom), fieldPath).values, fieldPath), (ctx, value) => {
109
+ const currentState = getStateOrThrow(ctx.get(stateAtom), fieldPath);
110
+ ctx.set(stateAtom, Option.some(operations.setFieldValue(currentState, fieldPath, value)));
111
+ }).pipe(Atom.setIdleTTL(0));
112
+ const initialValueAtom = Atom.readable((get) => getNestedValue(getStateOrThrow(get(stateAtom), fieldPath).initialValues, fieldPath)).pipe(Atom.setIdleTTL(0));
113
+ const touchedAtom = Atom.writable((get) => (getNestedValue(getStateOrThrow(get(stateAtom), fieldPath).touched, fieldPath) ?? false), (ctx, value) => {
114
+ const currentState = getStateOrThrow(ctx.get(stateAtom), fieldPath);
115
+ ctx.set(stateAtom, Option.some({
116
+ ...currentState,
117
+ touched: setNestedValue(currentState.touched, fieldPath, value),
118
+ }));
119
+ }).pipe(Atom.setIdleTTL(0));
120
+ const errorAtom = Atom.readable((get) => {
121
+ const errors = get(errorsAtom);
122
+ const entry = errors.get(fieldPath);
123
+ return entry
124
+ ? Option.some(entry)
125
+ : Option.none();
126
+ }).pipe(Atom.setIdleTTL(0));
127
+ const isDirtyAtom = isDirtyAtomFamily(fieldPath);
128
+ const validationAtom = getOrCreateValidationAtom(fieldPath, schema);
129
+ const fieldValidationCountAtom = Atom.make(0).pipe(Atom.setIdleTTL(0));
130
+ const shouldValidateAtom = Atom.readable((get) => {
131
+ if (parsedMode.validation === "onChange")
132
+ return true;
133
+ if (parsedMode.validation === "onBlur")
134
+ return get(touchedAtom) || get(fieldValidationCountAtom) > 0;
135
+ return (get(submitCountAtom) > 0 ||
136
+ get(validationCountAtom) > 0 ||
137
+ get(fieldValidationCountAtom) > 0);
138
+ }).pipe(Atom.setIdleTTL(0));
139
+ const displayErrorAtom = Atom.readable((get) => {
140
+ const validationResult = get(validationAtom);
141
+ const storedError = get(errorAtom);
142
+ const isDirty = get(isDirtyAtom);
143
+ const isTouched = get(touchedAtom);
144
+ const submitCount = get(submitCountAtom);
145
+ const livePerFieldError = Option.flatMap(AsyncResult.error(validationResult), Validation.extractFirstError);
146
+ let validationError = Option.none();
147
+ if (Option.isSome(livePerFieldError)) {
148
+ validationError = livePerFieldError;
149
+ }
150
+ else if (Option.isSome(storedError)) {
151
+ const shouldHideStoredError = storedError.value.source === "field" &&
152
+ (AsyncResult.isSuccess(validationResult) ||
153
+ AsyncResult.isWaiting(validationResult));
154
+ if (!shouldHideStoredError) {
155
+ validationError = Option.some(storedError.value.message);
156
+ }
157
+ }
158
+ const validationCount = get(validationCountAtom);
159
+ const fieldValidationCount = get(fieldValidationCountAtom);
160
+ const hasAttemptedValidation = submitCount > 0 || validationCount > 0 || fieldValidationCount > 0;
161
+ const shouldShowError = parsedMode.validation === "onChange"
162
+ ? isDirty || hasAttemptedValidation
163
+ : parsedMode.validation === "onBlur"
164
+ ? isTouched || hasAttemptedValidation
165
+ : hasAttemptedValidation;
166
+ return shouldShowError ? validationError : Option.none();
167
+ }).pipe(Atom.setIdleTTL(0));
168
+ // Every value change produces a fresh box, so `Atom.debounce` (which drops
169
+ // updates that are `Object.is`-equal to its current value) still emits when
170
+ // the value returns to what it was before the burst of changes.
171
+ const debouncedChangeAtom = validationDebounce === null
172
+ ? null
173
+ : Atom.debounce(Atom.readable((get) => ({ value: get(valueAtom) })).pipe(Atom.setIdleTTL(0)), validationDebounce);
174
+ const triggerValidationAtom = Atom.readable((get) => {
175
+ if (debouncedChangeAtom !== null) {
176
+ get.mount(debouncedChangeAtom);
177
+ get.subscribe(debouncedChangeAtom, (change) => {
178
+ if (!get.once(shouldValidateAtom))
179
+ return;
180
+ get.set(validationAtom, change.value);
181
+ });
182
+ }
183
+ else {
184
+ let lastValue = get.once(valueAtom);
185
+ get.subscribe(valueAtom, (newValue) => {
186
+ if (newValue === lastValue)
187
+ return;
188
+ lastValue = newValue;
189
+ if (!get.once(shouldValidateAtom))
190
+ return;
191
+ get.set(validationAtom, newValue);
192
+ });
193
+ }
194
+ if (parsedMode.validation === "onBlur") {
195
+ get.subscribe(touchedAtom, (isTouched) => {
196
+ if (isTouched) {
197
+ const currentValue = get.once(valueAtom);
198
+ get.set(validationAtom, currentValue);
199
+ }
200
+ });
201
+ }
202
+ }).pipe(Atom.setIdleTTL(0));
203
+ return {
204
+ valueAtom,
205
+ initialValueAtom,
206
+ touchedAtom,
207
+ errorAtom,
208
+ isDirtyAtom,
209
+ validationAtom,
210
+ fieldValidationCountAtom,
211
+ displayErrorAtom,
212
+ shouldValidateAtom,
213
+ triggerValidationAtom,
214
+ };
215
+ });
216
+ const getOrCreateFieldAtoms = (fieldPath, schema) => {
217
+ const key = { path: fieldPath, schema };
218
+ fieldAtomsKeys.set(fieldPath, key);
219
+ return fieldAtomsFamily(key);
220
+ };
221
+ const resetValidationAtoms = (ctx) => {
222
+ for (const key of validationKeys.values()) {
223
+ ctx.set(validationAtomFamily(key), Atom.Reset);
224
+ }
225
+ for (const key of fieldAtomsKeys.values()) {
226
+ ctx.set(fieldAtomsFamily(key).fieldValidationCountAtom, 0);
227
+ }
228
+ };
229
+ const submitAtom = runtime
230
+ .fn()((args, get) => Effect.gen(function* () {
231
+ const state = get(stateAtom);
232
+ if (Option.isNone(state)) {
233
+ return yield* Effect.die(new Error("submit was called before the form was initialized — mount " +
234
+ "<form.Initialize defaultValues={...}> before submitting. " +
235
+ `See the "Basic Form Setup" section of the README.`));
236
+ }
237
+ const values = state.value.values;
238
+ get.set(errorsAtom, new Map());
239
+ const decoded = yield* pipe(Schema.decodeUnknownEffect(combinedSchema)(values, {
240
+ errors: "all",
241
+ }), Effect.tapError((parseError) => Effect.sync(() => {
242
+ const routedErrors = Validation.routeErrorsWithSource(parseError);
243
+ get.set(errorsAtom, routedErrors);
244
+ // Rebase onto the latest state so edits made during the in-flight
245
+ // async decode are preserved instead of clobbered by the snapshot.
246
+ const latest = get(stateAtom);
247
+ const base = Option.isSome(latest) ? latest.value : state.value;
248
+ get.set(stateAtom, Option.some(operations.createSubmitState(base)));
249
+ })));
250
+ // Rebase onto the latest state so a field edit made while the async
251
+ // decode was running is not silently reverted to the pre-submit snapshot.
252
+ const latestState = get(stateAtom);
253
+ const baseState = Option.isSome(latestState)
254
+ ? latestState.value
255
+ : state.value;
256
+ const submitState = operations.createSubmitState(baseState);
257
+ get.set(stateAtom, Option.some(submitState));
258
+ const result = config.onSubmit(args, {
259
+ decoded,
260
+ encoded: values,
261
+ get,
262
+ });
263
+ const output = Effect.isEffect(result)
264
+ ? yield* result
265
+ : result;
266
+ // Only record the values as "last submitted" once onSubmit has
267
+ // succeeded. A failed onSubmit must not be reported as a successful
268
+ // submit, otherwise revertToLastSubmit / hasChangedSinceSubmit would
269
+ // treat unsaved, failed values as persisted.
270
+ const afterSubmit = get(stateAtom);
271
+ if (Option.isSome(afterSubmit)) {
272
+ get.set(stateAtom, Option.some({
273
+ ...afterSubmit.value,
274
+ lastSubmittedValues: Option.some({ encoded: values, decoded }),
275
+ }));
276
+ }
277
+ return output;
278
+ }), config.reactivityKeys
279
+ ? { reactivityKeys: config.reactivityKeys }
280
+ : undefined)
281
+ .pipe(Atom.setIdleTTL(0));
282
+ const validateAtom = runtime
283
+ .fn()((_, get) => Effect.gen(function* () {
284
+ const state = get(stateAtom);
285
+ if (Option.isNone(state))
286
+ return;
287
+ const values = state.value.values;
288
+ get.set(errorsAtom, new Map());
289
+ yield* pipe(Schema.decodeUnknownEffect(combinedSchema)(values, {
290
+ errors: "all",
291
+ }), Effect.catchTag("SchemaError", (parseError) => Effect.sync(() => {
292
+ const routedErrors = Validation.routeErrorsWithSource(parseError);
293
+ get.set(errorsAtom, routedErrors);
294
+ })));
295
+ const currentState = get(stateAtom);
296
+ if (Option.isSome(currentState)) {
297
+ get.set(stateAtom, Option.some({
298
+ ...currentState.value,
299
+ validationCount: currentState.value.validationCount + 1,
300
+ }));
301
+ }
302
+ }))
303
+ .pipe(Atom.setIdleTTL(0));
304
+ const fieldRefs = Object.fromEntries(Object.keys(fields).map((key) => [key, FormBuilder.makeFieldRef(key)]));
305
+ const operations = {
306
+ createInitialState: (defaultValues) => ({
307
+ values: defaultValues,
308
+ initialValues: defaultValues,
309
+ lastSubmittedValues: Option.none(),
310
+ touched: Field.createTouchedRecord(fields, false),
311
+ submitCount: 0,
312
+ validationCount: 0,
313
+ dirtyFields: new Set(),
314
+ }),
315
+ createResetState: (state) => ({
316
+ values: state.initialValues,
317
+ initialValues: state.initialValues,
318
+ lastSubmittedValues: Option.none(),
319
+ touched: Field.createTouchedRecord(fields, false),
320
+ submitCount: 0,
321
+ validationCount: 0,
322
+ dirtyFields: new Set(),
323
+ }),
324
+ createSubmitState: (state) => ({
325
+ ...state,
326
+ touched: Field.createTouchedRecord(fields, true),
327
+ submitCount: state.submitCount + 1,
328
+ }),
329
+ setFieldValue: (state, fieldPath, value) => {
330
+ const newValues = setNestedValue(state.values, fieldPath, value);
331
+ const newDirtyFields = recalculateDirtySubtree(state.dirtyFields, state.initialValues, newValues, fieldPath);
332
+ return {
333
+ ...state,
334
+ values: newValues,
335
+ dirtyFields: newDirtyFields,
336
+ };
337
+ },
338
+ setFormValues: (state, values) => {
339
+ const newDirtyFields = recalculateDirtySubtree(state.dirtyFields, state.initialValues, values, "");
340
+ return {
341
+ ...state,
342
+ values,
343
+ dirtyFields: newDirtyFields,
344
+ };
345
+ },
346
+ setFieldTouched: (state, fieldPath, touched) => ({
347
+ ...state,
348
+ touched: setNestedValue(state.touched, fieldPath, touched),
349
+ }),
350
+ appendArrayItem: (state, arrayPath, itemSchema, value) => {
351
+ const newItem = value ?? Field.getDefaultFromSchema(itemSchema);
352
+ const currentItems = (getNestedValue(state.values, arrayPath) ??
353
+ []);
354
+ const newItems = [...currentItems, newItem];
355
+ return {
356
+ ...state,
357
+ values: setNestedValue(state.values, arrayPath, newItems),
358
+ dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
359
+ };
360
+ },
361
+ removeArrayItem: (state, arrayPath, index) => {
362
+ const currentItems = (getNestedValue(state.values, arrayPath) ??
363
+ []);
364
+ const newItems = currentItems.filter((_, i) => i !== index);
365
+ return {
366
+ ...state,
367
+ values: setNestedValue(state.values, arrayPath, newItems),
368
+ dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
369
+ };
370
+ },
371
+ swapArrayItems: (state, arrayPath, indexA, indexB) => {
372
+ const currentItems = (getNestedValue(state.values, arrayPath) ??
373
+ []);
374
+ if (indexA < 0 ||
375
+ indexA >= currentItems.length ||
376
+ indexB < 0 ||
377
+ indexB >= currentItems.length ||
378
+ indexA === indexB) {
379
+ return state;
380
+ }
381
+ const newItems = [...currentItems];
382
+ const temp = newItems[indexA];
383
+ newItems[indexA] = newItems[indexB];
384
+ newItems[indexB] = temp;
385
+ return {
386
+ ...state,
387
+ values: setNestedValue(state.values, arrayPath, newItems),
388
+ dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
389
+ };
390
+ },
391
+ moveArrayItem: (state, arrayPath, fromIndex, toIndex) => {
392
+ const currentItems = (getNestedValue(state.values, arrayPath) ??
393
+ []);
394
+ if (fromIndex < 0 ||
395
+ fromIndex >= currentItems.length ||
396
+ toIndex < 0 ||
397
+ toIndex > currentItems.length ||
398
+ fromIndex === toIndex) {
399
+ return state;
400
+ }
401
+ const newItems = [...currentItems];
402
+ const [item] = newItems.splice(fromIndex, 1);
403
+ newItems.splice(toIndex, 0, item);
404
+ return {
405
+ ...state,
406
+ values: setNestedValue(state.values, arrayPath, newItems),
407
+ dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
408
+ };
409
+ },
410
+ revertToLastSubmit: (state) => {
411
+ if (Option.isNone(state.lastSubmittedValues)) {
412
+ return state;
413
+ }
414
+ const lastEncoded = state.lastSubmittedValues.value.encoded;
415
+ if (state.values === lastEncoded) {
416
+ return state;
417
+ }
418
+ const newDirtyFields = recalculateDirtySubtree(state.dirtyFields, state.initialValues, lastEncoded, "");
419
+ return {
420
+ ...state,
421
+ values: lastEncoded,
422
+ dirtyFields: newDirtyFields,
423
+ };
424
+ },
425
+ };
426
+ const resetAtom = Atom.fnSync()((_, get) => {
427
+ const state = get(stateAtom);
428
+ if (Option.isNone(state))
429
+ return;
430
+ get.set(stateAtom, Option.some(operations.createResetState(state.value)));
431
+ get.set(errorsAtom, new Map());
432
+ resetValidationAtoms(get);
433
+ get.set(submitAtom, Atom.Reset);
434
+ get.set(validateAtom, Atom.Reset);
435
+ }, { initialValue: undefined }).pipe(Atom.setIdleTTL(0));
436
+ const revertToLastSubmitAtom = Atom.fnSync()((_, get) => {
437
+ const state = get(stateAtom);
438
+ if (Option.isNone(state))
439
+ return;
440
+ get.set(stateAtom, Option.some(operations.revertToLastSubmit(state.value)));
441
+ get.set(errorsAtom, new Map());
442
+ }, { initialValue: undefined }).pipe(Atom.setIdleTTL(0));
443
+ const setValuesAtom = Atom.writable((get) => pipe(get(stateAtom), Option.map((s) => s.values), Option.getOrElse(() => undefined)), (ctx, values) => {
444
+ const state = ctx.get(stateAtom);
445
+ if (Option.isNone(state))
446
+ return;
447
+ ctx.set(stateAtom, Option.some(operations.setFormValues(state.value, values)));
448
+ ctx.set(errorsAtom, new Map());
449
+ }).pipe(Atom.setIdleTTL(0));
450
+ const setValueFamily = Atom.family((fieldKey) => Atom.fnSync()((update, get) => {
451
+ const state = get(stateAtom);
452
+ if (Option.isNone(state))
453
+ return;
454
+ const currentValue = getNestedValue(state.value.values, fieldKey);
455
+ const newValue = typeof update === "function" ? update(currentValue) : update;
456
+ get.set(stateAtom, Option.some(operations.setFieldValue(state.value, fieldKey, newValue)));
457
+ // Don't clear errors - display logic handles showing/hiding based on source + validation state
458
+ }, { initialValue: undefined }).pipe(Atom.setIdleTTL(0)));
459
+ const publicFieldAtomsFamily = Atom.family((fieldKey) => {
460
+ const schema = fieldSchemasByKey.get(fieldKey);
461
+ if (!schema)
462
+ throw new Error(`No schema found for field "${fieldKey}"`);
463
+ const internal = getOrCreateFieldAtoms(fieldKey, schema);
464
+ const value = Atom.readable((get) => Option.map(get(stateAtom), (state) => getNestedValue(state.values, fieldKey))).pipe(Atom.setIdleTTL(0));
465
+ const error = Atom.readable((get) => Option.match(get(stateAtom), {
466
+ onNone: () => Option.none(),
467
+ onSome: () => get(internal.displayErrorAtom),
468
+ })).pipe(Atom.setIdleTTL(0));
469
+ const isDirty = isDirtyAtomFamily(fieldKey);
470
+ const isTouched = Atom.readable((get) => Option.match(get(stateAtom), {
471
+ onNone: () => false,
472
+ onSome: (state) => (getNestedValue(state.touched, fieldKey) ?? false),
473
+ })).pipe(Atom.setIdleTTL(0));
474
+ const isValidating = Atom.readable((get) => AsyncResult.isWaiting(get(internal.validationAtom))).pipe(Atom.setIdleTTL(0));
475
+ const setValueAtom = setValueFamily(fieldKey);
476
+ const setTouchedAtom = Atom.fnSync()((touched, get) => {
477
+ const state = get(stateAtom);
478
+ if (Option.isNone(state))
479
+ return;
480
+ get.set(stateAtom, Option.some(operations.setFieldTouched(state.value, fieldKey, touched)));
481
+ }, { initialValue: undefined }).pipe(Atom.setIdleTTL(0));
482
+ const validateFieldAtom = Atom.fnSync()((_, get) => {
483
+ const value = get(internal.valueAtom);
484
+ get.set(internal.validationAtom, value);
485
+ get.set(internal.fieldValidationCountAtom, get(internal.fieldValidationCountAtom) + 1);
486
+ }, { initialValue: undefined }).pipe(Atom.setIdleTTL(0));
487
+ return {
488
+ value,
489
+ error,
490
+ isDirty,
491
+ isTouched,
492
+ isValidating,
493
+ setValue: setValueAtom,
494
+ setTouched: setTouchedAtom,
495
+ validate: validateFieldAtom,
496
+ };
497
+ });
498
+ const getFieldAtoms = (field) => publicFieldAtomsFamily(field.key);
499
+ const mountAtom = Atom.readable((get) => {
500
+ get(stateAtom);
501
+ get(errorsAtom);
502
+ get(submitAtom);
503
+ }).pipe(Atom.setIdleTTL(0));
504
+ const keepAliveActiveAtom = Atom.make(false).pipe(Atom.setIdleTTL(0));
505
+ const autoSubmitAtom = parsedMode.autoSubmit && parsedMode.validation === "onChange"
506
+ ? (() => {
507
+ // Submit requests are funneled through a monotonically increasing counter
508
+ // so `Atom.debounce` can own the timer lifecycle: every bump restarts the
509
+ // trailing debounce window, and the subscriber below fires once it lands.
510
+ const submitRequestAtom = Atom.make(0).pipe(Atom.setIdleTTL(0));
511
+ const debouncedSubmitRequestAtom = autoSubmitDebounce === null
512
+ ? null
513
+ : Atom.debounce(submitRequestAtom, autoSubmitDebounce);
514
+ return Atom.readable((get) => {
515
+ const initialState = get.once(stateAtom);
516
+ let lastValues = Option.isSome(initialState)
517
+ ? initialState.value.values
518
+ : null;
519
+ let pendingChanges = false;
520
+ let wasSubmitting = false;
521
+ const triggerSubmit = () => {
522
+ if (AsyncResult.isWaiting(get.once(submitAtom))) {
523
+ pendingChanges = true;
524
+ return;
525
+ }
526
+ get.set(submitAtom, undefined);
527
+ };
528
+ let requestSubmit;
529
+ if (debouncedSubmitRequestAtom === null) {
530
+ requestSubmit = triggerSubmit;
531
+ }
532
+ else {
533
+ get.mount(debouncedSubmitRequestAtom);
534
+ get.subscribe(debouncedSubmitRequestAtom, () => {
535
+ triggerSubmit();
536
+ });
537
+ requestSubmit = () => {
538
+ get.set(submitRequestAtom, get.once(submitRequestAtom) + 1);
539
+ };
540
+ }
541
+ get.subscribe(stateAtom, () => {
542
+ const state = get.once(stateAtom);
543
+ if (Option.isNone(state))
544
+ return;
545
+ const currentValues = state.value.values;
546
+ if (currentValues === lastValues)
547
+ return;
548
+ lastValues = currentValues;
549
+ const submitResult = get.once(submitAtom);
550
+ if (AsyncResult.isWaiting(submitResult)) {
551
+ pendingChanges = true;
552
+ }
553
+ else {
554
+ requestSubmit();
555
+ }
556
+ });
557
+ get.subscribe(submitAtom, () => {
558
+ const result = get.once(submitAtom);
559
+ const isSubmitting = AsyncResult.isWaiting(result);
560
+ const justFinished = wasSubmitting && !isSubmitting;
561
+ // Update wasSubmitting BEFORE triggering a follow-up submit. requestSubmit
562
+ // (no debounce) synchronously re-enters this subscription with the new
563
+ // waiting=true state; if we assigned wasSubmitting afterwards we'd clobber
564
+ // that re-entrant true with the stale false, losing the next change.
565
+ wasSubmitting = isSubmitting;
566
+ if (justFinished && pendingChanges) {
567
+ pendingChanges = false;
568
+ requestSubmit();
569
+ }
570
+ });
571
+ }).pipe(Atom.setIdleTTL(0));
572
+ })()
573
+ : Atom.readable(() => { }).pipe(Atom.setIdleTTL(0));
574
+ const onBlurSubmitAtom = parsedMode.autoSubmit && parsedMode.validation === "onBlur"
575
+ ? Atom.fnSync()((_, get) => {
576
+ if (AsyncResult.isWaiting(get(submitAtom)))
577
+ return;
578
+ const stateOption = get(stateAtom);
579
+ if (Option.isNone(stateOption))
580
+ return;
581
+ const { lastSubmittedValues, values } = stateOption.value;
582
+ if (Option.isSome(lastSubmittedValues) &&
583
+ values === lastSubmittedValues.value.encoded)
584
+ return;
585
+ get.set(submitAtom, undefined);
586
+ }, { initialValue: undefined }).pipe(Atom.setIdleTTL(0))
587
+ : Atom.fnSync()((_) => { }, {
588
+ initialValue: undefined,
589
+ }).pipe(Atom.setIdleTTL(0));
590
+ return {
591
+ stateAtom,
592
+ errorsAtom,
593
+ rootErrorAtom,
594
+ valuesAtom,
595
+ dirtyFieldsAtom,
596
+ isDirtyAtom,
597
+ submitCountAtom,
598
+ validationCountAtom,
599
+ lastSubmittedValuesAtom,
600
+ changedSinceSubmitFieldsAtom,
601
+ hasChangedSinceSubmitAtom,
602
+ submitAtom,
603
+ validateAtom,
604
+ combinedSchema,
605
+ fieldRefs,
606
+ getOrCreateValidationAtom,
607
+ getOrCreateFieldAtoms,
608
+ resetValidationAtoms,
609
+ operations,
610
+ resetAtom,
611
+ revertToLastSubmitAtom,
612
+ setValuesAtom,
613
+ getFieldAtoms,
614
+ autoSubmitAtom,
615
+ onBlurSubmitAtom,
616
+ mountAtom,
617
+ keepAliveActiveAtom,
618
+ };
619
+ };
620
+ //# sourceMappingURL=FormAtoms.js.map