@lucas-barake/effect-form 0.1.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 (64) hide show
  1. package/Form/package.json +6 -0
  2. package/FormAtoms/package.json +6 -0
  3. package/LICENSE +21 -0
  4. package/Mode/package.json +6 -0
  5. package/README.md +5 -0
  6. package/Validation/package.json +6 -0
  7. package/dist/cjs/Form.js +299 -0
  8. package/dist/cjs/Form.js.map +1 -0
  9. package/dist/cjs/FormAtoms.js +266 -0
  10. package/dist/cjs/FormAtoms.js.map +1 -0
  11. package/dist/cjs/Mode.js +64 -0
  12. package/dist/cjs/Mode.js.map +1 -0
  13. package/dist/cjs/Validation.js +69 -0
  14. package/dist/cjs/Validation.js.map +1 -0
  15. package/dist/cjs/index.js +35 -0
  16. package/dist/cjs/index.js.map +1 -0
  17. package/dist/cjs/internal/dirty.js +101 -0
  18. package/dist/cjs/internal/dirty.js.map +1 -0
  19. package/dist/cjs/internal/path.js +96 -0
  20. package/dist/cjs/internal/path.js.map +1 -0
  21. package/dist/cjs/internal/weak-registry.js +52 -0
  22. package/dist/cjs/internal/weak-registry.js.map +1 -0
  23. package/dist/dts/Form.d.ts +317 -0
  24. package/dist/dts/Form.d.ts.map +1 -0
  25. package/dist/dts/FormAtoms.d.ts +145 -0
  26. package/dist/dts/FormAtoms.d.ts.map +1 -0
  27. package/dist/dts/Mode.d.ts +55 -0
  28. package/dist/dts/Mode.d.ts.map +1 -0
  29. package/dist/dts/Validation.d.ts +23 -0
  30. package/dist/dts/Validation.d.ts.map +1 -0
  31. package/dist/dts/index.d.ts +26 -0
  32. package/dist/dts/index.d.ts.map +1 -0
  33. package/dist/dts/internal/dirty.d.ts +13 -0
  34. package/dist/dts/internal/dirty.d.ts.map +1 -0
  35. package/dist/dts/internal/path.d.ts +32 -0
  36. package/dist/dts/internal/path.d.ts.map +1 -0
  37. package/dist/dts/internal/weak-registry.d.ts +7 -0
  38. package/dist/dts/internal/weak-registry.d.ts.map +1 -0
  39. package/dist/esm/Form.js +263 -0
  40. package/dist/esm/Form.js.map +1 -0
  41. package/dist/esm/FormAtoms.js +238 -0
  42. package/dist/esm/FormAtoms.js.map +1 -0
  43. package/dist/esm/Mode.js +36 -0
  44. package/dist/esm/Mode.js.map +1 -0
  45. package/dist/esm/Validation.js +40 -0
  46. package/dist/esm/Validation.js.map +1 -0
  47. package/dist/esm/index.js +26 -0
  48. package/dist/esm/index.js.map +1 -0
  49. package/dist/esm/internal/dirty.js +72 -0
  50. package/dist/esm/internal/dirty.js.map +1 -0
  51. package/dist/esm/internal/path.js +86 -0
  52. package/dist/esm/internal/path.js.map +1 -0
  53. package/dist/esm/internal/weak-registry.js +45 -0
  54. package/dist/esm/internal/weak-registry.js.map +1 -0
  55. package/dist/esm/package.json +4 -0
  56. package/package.json +64 -0
  57. package/src/Form.ts +522 -0
  58. package/src/FormAtoms.ts +485 -0
  59. package/src/Mode.ts +59 -0
  60. package/src/Validation.ts +43 -0
  61. package/src/index.ts +28 -0
  62. package/src/internal/dirty.ts +96 -0
  63. package/src/internal/path.ts +93 -0
  64. package/src/internal/weak-registry.ts +60 -0
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Atom infrastructure for form state management.
3
+ *
4
+ * This module provides the core atom infrastructure that framework adapters
5
+ * (React, Vue, Svelte, Solid) can use to build reactive form components.
6
+ *
7
+ * @since 1.0.0
8
+ */
9
+ import * as Atom from "@effect-atom/atom/Atom"
10
+ import type * as Registry from "@effect-atom/atom/Registry"
11
+ import * as Effect from "effect/Effect"
12
+ import * as Equal from "effect/Equal"
13
+ import { pipe } from "effect/Function"
14
+ import * as Option from "effect/Option"
15
+ import type * as ParseResult from "effect/ParseResult"
16
+ import * as Schema from "effect/Schema"
17
+ import * as Utils from "effect/Utils"
18
+ import type * as Form from "./Form.js"
19
+ import { buildSchema, createTouchedRecord, getDefaultFromSchema, makeFieldRef } from "./Form.js"
20
+ import { recalculateDirtyFieldsForArray, recalculateDirtySubtree } from "./internal/dirty.js"
21
+ import { getNestedValue, setNestedValue } from "./internal/path.js"
22
+ import { createWeakRegistry, type WeakRegistry } from "./internal/weak-registry.js"
23
+
24
+ /**
25
+ * Atoms for a single field.
26
+ *
27
+ * @since 1.0.0
28
+ * @category Models
29
+ */
30
+ export interface FieldAtoms {
31
+ readonly valueAtom: Atom.Writable<unknown, unknown>
32
+ readonly initialValueAtom: Atom.Atom<unknown>
33
+ readonly touchedAtom: Atom.Writable<boolean, boolean>
34
+ readonly crossFieldErrorAtom: Atom.Atom<Option.Option<string>>
35
+ }
36
+
37
+ /**
38
+ * Configuration for creating form atoms.
39
+ *
40
+ * @since 1.0.0
41
+ * @category Models
42
+ */
43
+ export interface FormAtomsConfig<TFields extends Form.FieldsRecord, R> {
44
+ readonly runtime: Atom.AtomRuntime<R, any>
45
+ readonly formBuilder: Form.FormBuilder<TFields, R>
46
+ }
47
+
48
+ /**
49
+ * Maps field names to their type-safe Field references for setValue operations.
50
+ *
51
+ * @since 1.0.0
52
+ * @category Models
53
+ */
54
+ export type FieldRefs<TFields extends Form.FieldsRecord> = {
55
+ readonly [K in keyof TFields]: TFields[K] extends Form.FieldDef<any, infer S> ? Form.Field<Schema.Schema.Encoded<S>>
56
+ : TFields[K] extends Form.ArrayFieldDef<any, infer S> ? Form.Field<ReadonlyArray<Schema.Schema.Encoded<S>>>
57
+ : never
58
+ }
59
+
60
+ /**
61
+ * The complete form atoms infrastructure.
62
+ *
63
+ * @since 1.0.0
64
+ * @category Models
65
+ */
66
+ export interface FormAtoms<TFields extends Form.FieldsRecord, R> {
67
+ readonly stateAtom: Atom.Writable<Option.Option<Form.FormState<TFields>>, Option.Option<Form.FormState<TFields>>>
68
+ readonly crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>
69
+ readonly dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>
70
+ readonly isDirtyAtom: Atom.Atom<boolean>
71
+ readonly submitCountAtom: Atom.Atom<number>
72
+ readonly onSubmitAtom: Atom.Writable<
73
+ Atom.AtomResultFn<Form.DecodedFromFields<TFields>, unknown, unknown> | null,
74
+ Atom.AtomResultFn<Form.DecodedFromFields<TFields>, unknown, unknown> | null
75
+ >
76
+
77
+ readonly decodeAndSubmit: Atom.AtomResultFn<Form.EncodedFromFields<TFields>, unknown, unknown>
78
+
79
+ readonly combinedSchema: Schema.Schema<Form.DecodedFromFields<TFields>, Form.EncodedFromFields<TFields>, R>
80
+
81
+ readonly fieldRefs: FieldRefs<TFields>
82
+
83
+ readonly validationAtomsRegistry: WeakRegistry<Atom.AtomResultFn<unknown, void, ParseResult.ParseError>>
84
+ readonly fieldAtomsRegistry: WeakRegistry<FieldAtoms>
85
+
86
+ readonly getOrCreateValidationAtom: (
87
+ fieldPath: string,
88
+ schema: Schema.Schema.Any,
89
+ ) => Atom.AtomResultFn<unknown, void, ParseResult.ParseError>
90
+
91
+ readonly getOrCreateFieldAtoms: (fieldPath: string) => FieldAtoms
92
+
93
+ readonly resetValidationAtoms: (registry: Registry.Registry) => void
94
+
95
+ readonly operations: FormOperations<TFields>
96
+ }
97
+
98
+ /**
99
+ * Pure state operations for form manipulation.
100
+ *
101
+ * @since 1.0.0
102
+ * @category Models
103
+ */
104
+ export interface FormOperations<TFields extends Form.FieldsRecord> {
105
+ /**
106
+ * Creates the initial form state from default values.
107
+ */
108
+ readonly createInitialState: (defaultValues: Form.EncodedFromFields<TFields>) => Form.FormState<TFields>
109
+
110
+ /**
111
+ * Creates a reset state (back to initial values).
112
+ */
113
+ readonly createResetState: (state: Form.FormState<TFields>) => Form.FormState<TFields>
114
+
115
+ /**
116
+ * Creates state with all fields marked as touched and submit count incremented.
117
+ */
118
+ readonly createSubmitState: (state: Form.FormState<TFields>) => Form.FormState<TFields>
119
+
120
+ /**
121
+ * Updates a single field value in the state.
122
+ */
123
+ readonly setFieldValue: (
124
+ state: Form.FormState<TFields>,
125
+ fieldPath: string,
126
+ value: unknown,
127
+ ) => Form.FormState<TFields>
128
+
129
+ /**
130
+ * Sets all form values, recalculating dirty fields.
131
+ */
132
+ readonly setFormValues: (
133
+ state: Form.FormState<TFields>,
134
+ values: Form.EncodedFromFields<TFields>,
135
+ ) => Form.FormState<TFields>
136
+
137
+ /**
138
+ * Sets a field as touched.
139
+ */
140
+ readonly setFieldTouched: (
141
+ state: Form.FormState<TFields>,
142
+ fieldPath: string,
143
+ touched: boolean,
144
+ ) => Form.FormState<TFields>
145
+
146
+ /**
147
+ * Appends an item to an array field.
148
+ */
149
+ readonly appendArrayItem: (
150
+ state: Form.FormState<TFields>,
151
+ arrayPath: string,
152
+ itemSchema: Schema.Schema.Any,
153
+ value?: unknown,
154
+ ) => Form.FormState<TFields>
155
+
156
+ /**
157
+ * Removes an item from an array field.
158
+ */
159
+ readonly removeArrayItem: (
160
+ state: Form.FormState<TFields>,
161
+ arrayPath: string,
162
+ index: number,
163
+ ) => Form.FormState<TFields>
164
+
165
+ /**
166
+ * Swaps two items in an array field.
167
+ */
168
+ readonly swapArrayItems: (
169
+ state: Form.FormState<TFields>,
170
+ arrayPath: string,
171
+ indexA: number,
172
+ indexB: number,
173
+ ) => Form.FormState<TFields>
174
+
175
+ /**
176
+ * Moves an item in an array field.
177
+ */
178
+ readonly moveArrayItem: (
179
+ state: Form.FormState<TFields>,
180
+ arrayPath: string,
181
+ fromIndex: number,
182
+ toIndex: number,
183
+ ) => Form.FormState<TFields>
184
+ }
185
+
186
+ /**
187
+ * Creates the complete form atoms infrastructure.
188
+ *
189
+ * @example
190
+ * ```ts
191
+ * import * as FormAtoms from "@lucas-barake/effect-form/FormAtoms"
192
+ * import * as Form from "@lucas-barake/effect-form"
193
+ * import * as Atom from "@effect-atom/atom/Atom"
194
+ * import * as Layer from "effect/Layer"
195
+ *
196
+ * const runtime = Atom.runtime(Layer.empty)
197
+ *
198
+ * const loginForm = Form.empty
199
+ * .addField(Form.makeField("email", Schema.String))
200
+ * .addField(Form.makeField("password", Schema.String))
201
+ *
202
+ * const atoms = FormAtoms.make({
203
+ * runtime,
204
+ * formBuilder: loginForm,
205
+ * parsedMode: { validation: "onChange", debounce: 300, autoSubmit: false }
206
+ * })
207
+ * ```
208
+ *
209
+ * @since 1.0.0
210
+ * @category Constructors
211
+ */
212
+ export const make = <TFields extends Form.FieldsRecord, R>(
213
+ config: FormAtomsConfig<TFields, R>,
214
+ ): FormAtoms<TFields, R> => {
215
+ const { formBuilder, runtime } = config
216
+ const { fields } = formBuilder
217
+
218
+ const combinedSchema = buildSchema(formBuilder)
219
+
220
+ const stateAtom = Atom.make(Option.none<Form.FormState<TFields>>()).pipe(Atom.setIdleTTL(0))
221
+ const crossFieldErrorsAtom = Atom.make<Map<string, string>>(new Map()).pipe(Atom.setIdleTTL(0))
222
+
223
+ const dirtyFieldsAtom = Atom.readable((get) => Option.getOrThrow(get(stateAtom)).dirtyFields).pipe(
224
+ Atom.setIdleTTL(0),
225
+ )
226
+
227
+ const isDirtyAtom = Atom.readable((get) => Option.getOrThrow(get(stateAtom)).dirtyFields.size > 0).pipe(
228
+ Atom.setIdleTTL(0),
229
+ )
230
+
231
+ const submitCountAtom = Atom.readable((get) => Option.getOrThrow(get(stateAtom)).submitCount).pipe(
232
+ Atom.setIdleTTL(0),
233
+ )
234
+
235
+ const onSubmitAtom = Atom.make<Atom.AtomResultFn<Form.DecodedFromFields<TFields>, unknown, unknown> | null>(
236
+ null,
237
+ ).pipe(Atom.setIdleTTL(0))
238
+
239
+ const updateDirtyFields = (
240
+ state: Form.FormState<TFields>,
241
+ fieldPath: string,
242
+ newValue: unknown,
243
+ ): ReadonlySet<string> => {
244
+ const initialValue = getNestedValue(state.initialValues, fieldPath)
245
+ const isEqual = Utils.structuralRegion(() => Equal.equals(newValue, initialValue))
246
+
247
+ const newDirtyFields = new Set(state.dirtyFields)
248
+ if (!isEqual) {
249
+ newDirtyFields.add(fieldPath)
250
+ } else {
251
+ newDirtyFields.delete(fieldPath)
252
+ }
253
+ return newDirtyFields
254
+ }
255
+
256
+ const validationAtomsRegistry = createWeakRegistry<Atom.AtomResultFn<unknown, void, ParseResult.ParseError>>()
257
+ const fieldAtomsRegistry = createWeakRegistry<FieldAtoms>()
258
+
259
+ const getOrCreateValidationAtom = (
260
+ fieldPath: string,
261
+ schema: Schema.Schema.Any,
262
+ ): Atom.AtomResultFn<unknown, void, ParseResult.ParseError> => {
263
+ const existing = validationAtomsRegistry.get(fieldPath)
264
+ if (existing) return existing
265
+
266
+ const validationAtom = runtime.fn<unknown>()((value: unknown) =>
267
+ pipe(
268
+ Schema.decodeUnknown(schema)(value) as Effect.Effect<unknown, ParseResult.ParseError, R>,
269
+ Effect.asVoid,
270
+ )
271
+ ).pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<unknown, void, ParseResult.ParseError>
272
+
273
+ validationAtomsRegistry.set(fieldPath, validationAtom)
274
+ return validationAtom
275
+ }
276
+
277
+ const getOrCreateFieldAtoms = (fieldPath: string): FieldAtoms => {
278
+ const existing = fieldAtomsRegistry.get(fieldPath)
279
+ if (existing) return existing
280
+
281
+ const valueAtom = Atom.writable(
282
+ (get) => getNestedValue(Option.getOrThrow(get(stateAtom)).values, fieldPath),
283
+ (ctx, value) => {
284
+ const currentState = Option.getOrThrow(ctx.get(stateAtom))
285
+ ctx.set(
286
+ stateAtom,
287
+ Option.some({
288
+ ...currentState,
289
+ values: setNestedValue(currentState.values, fieldPath, value),
290
+ dirtyFields: updateDirtyFields(currentState, fieldPath, value),
291
+ }),
292
+ )
293
+ },
294
+ ).pipe(Atom.setIdleTTL(0))
295
+
296
+ const initialValueAtom = Atom.readable(
297
+ (get) => getNestedValue(Option.getOrThrow(get(stateAtom)).initialValues, fieldPath),
298
+ ).pipe(Atom.setIdleTTL(0))
299
+
300
+ const touchedAtom = Atom.writable(
301
+ (get) => (getNestedValue(Option.getOrThrow(get(stateAtom)).touched, fieldPath) ?? false) as boolean,
302
+ (ctx, value) => {
303
+ const currentState = Option.getOrThrow(ctx.get(stateAtom))
304
+ ctx.set(
305
+ stateAtom,
306
+ Option.some({
307
+ ...currentState,
308
+ touched: setNestedValue(currentState.touched, fieldPath, value),
309
+ }),
310
+ )
311
+ },
312
+ ).pipe(Atom.setIdleTTL(0))
313
+
314
+ const crossFieldErrorAtom = Atom.readable((get) => {
315
+ const errors = get(crossFieldErrorsAtom)
316
+ const error = errors.get(fieldPath)
317
+ return error !== undefined ? Option.some(error) : Option.none<string>()
318
+ }).pipe(Atom.setIdleTTL(0))
319
+
320
+ const atoms: FieldAtoms = { valueAtom, initialValueAtom, touchedAtom, crossFieldErrorAtom }
321
+ fieldAtomsRegistry.set(fieldPath, atoms)
322
+ return atoms
323
+ }
324
+
325
+ const resetValidationAtoms = (registry: Registry.Registry) => {
326
+ for (const validationAtom of validationAtomsRegistry.values()) {
327
+ registry.set(validationAtom, Atom.Reset)
328
+ }
329
+ validationAtomsRegistry.clear()
330
+ fieldAtomsRegistry.clear()
331
+ }
332
+
333
+ const decodeAndSubmit = runtime.fn<Form.EncodedFromFields<TFields>>()((values, get) =>
334
+ Effect.gen(function*() {
335
+ const decoded = yield* Schema.decodeUnknown(combinedSchema)(values) as Effect.Effect<
336
+ Form.DecodedFromFields<TFields>,
337
+ ParseResult.ParseError,
338
+ R
339
+ >
340
+ const onSubmit = get(onSubmitAtom)!
341
+ get.set(onSubmit, decoded)
342
+ return yield* get.result(onSubmit, { suspendOnWaiting: true })
343
+ })
344
+ ).pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<Form.EncodedFromFields<TFields>, unknown, unknown>
345
+
346
+ const fieldRefs = Object.fromEntries(
347
+ Object.keys(fields).map((key) => [key, makeFieldRef(key)]),
348
+ ) as FieldRefs<TFields>
349
+
350
+ const operations: FormOperations<TFields> = {
351
+ createInitialState: (defaultValues) => ({
352
+ values: defaultValues,
353
+ initialValues: defaultValues,
354
+ touched: createTouchedRecord(fields, false) as { readonly [K in keyof TFields]: boolean },
355
+ submitCount: 0,
356
+ dirtyFields: new Set(),
357
+ }),
358
+
359
+ createResetState: (state) => ({
360
+ values: state.initialValues,
361
+ initialValues: state.initialValues,
362
+ touched: createTouchedRecord(fields, false) as { readonly [K in keyof TFields]: boolean },
363
+ submitCount: 0,
364
+ dirtyFields: new Set(),
365
+ }),
366
+
367
+ createSubmitState: (state) => ({
368
+ ...state,
369
+ touched: createTouchedRecord(fields, true) as { readonly [K in keyof TFields]: boolean },
370
+ submitCount: state.submitCount + 1,
371
+ }),
372
+
373
+ setFieldValue: (state, fieldPath, value) => {
374
+ const newValues = setNestedValue(state.values, fieldPath, value)
375
+ const newDirtyFields = recalculateDirtySubtree(
376
+ state.dirtyFields,
377
+ state.initialValues,
378
+ newValues,
379
+ fieldPath,
380
+ )
381
+ return {
382
+ ...state,
383
+ values: newValues as Form.EncodedFromFields<TFields>,
384
+ dirtyFields: newDirtyFields,
385
+ }
386
+ },
387
+
388
+ setFormValues: (state, values) => {
389
+ const newDirtyFields = recalculateDirtySubtree(
390
+ state.dirtyFields,
391
+ state.initialValues,
392
+ values,
393
+ "",
394
+ )
395
+ return {
396
+ ...state,
397
+ values,
398
+ dirtyFields: newDirtyFields,
399
+ }
400
+ },
401
+
402
+ setFieldTouched: (state, fieldPath, touched) => ({
403
+ ...state,
404
+ touched: setNestedValue(state.touched, fieldPath, touched) as { readonly [K in keyof TFields]: boolean },
405
+ }),
406
+
407
+ appendArrayItem: (state, arrayPath, itemSchema, value) => {
408
+ const newItem = value ?? getDefaultFromSchema(itemSchema)
409
+ const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>
410
+ const newItems = [...currentItems, newItem]
411
+ return {
412
+ ...state,
413
+ values: setNestedValue(state.values, arrayPath, newItems) as Form.EncodedFromFields<TFields>,
414
+ dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
415
+ }
416
+ },
417
+
418
+ removeArrayItem: (state, arrayPath, index) => {
419
+ const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>
420
+ const newItems = currentItems.filter((_, i) => i !== index)
421
+ return {
422
+ ...state,
423
+ values: setNestedValue(state.values, arrayPath, newItems) as Form.EncodedFromFields<TFields>,
424
+ dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
425
+ }
426
+ },
427
+
428
+ swapArrayItems: (state, arrayPath, indexA, indexB) => {
429
+ const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>
430
+ if (
431
+ indexA < 0 || indexA >= currentItems.length ||
432
+ indexB < 0 || indexB >= currentItems.length ||
433
+ indexA === indexB
434
+ ) {
435
+ return state
436
+ }
437
+ const newItems = [...currentItems]
438
+ const temp = newItems[indexA]
439
+ newItems[indexA] = newItems[indexB]
440
+ newItems[indexB] = temp
441
+ return {
442
+ ...state,
443
+ values: setNestedValue(state.values, arrayPath, newItems) as Form.EncodedFromFields<TFields>,
444
+ dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
445
+ }
446
+ },
447
+
448
+ moveArrayItem: (state, arrayPath, fromIndex, toIndex) => {
449
+ const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>
450
+ if (
451
+ fromIndex < 0 || fromIndex >= currentItems.length ||
452
+ toIndex < 0 || toIndex > currentItems.length ||
453
+ fromIndex === toIndex
454
+ ) {
455
+ return state
456
+ }
457
+ const newItems = [...currentItems]
458
+ const [item] = newItems.splice(fromIndex, 1)
459
+ newItems.splice(toIndex, 0, item)
460
+ return {
461
+ ...state,
462
+ values: setNestedValue(state.values, arrayPath, newItems) as Form.EncodedFromFields<TFields>,
463
+ dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
464
+ }
465
+ },
466
+ }
467
+
468
+ return {
469
+ stateAtom,
470
+ crossFieldErrorsAtom,
471
+ dirtyFieldsAtom,
472
+ isDirtyAtom,
473
+ submitCountAtom,
474
+ onSubmitAtom,
475
+ decodeAndSubmit,
476
+ combinedSchema,
477
+ fieldRefs,
478
+ validationAtomsRegistry,
479
+ fieldAtomsRegistry,
480
+ getOrCreateValidationAtom,
481
+ getOrCreateFieldAtoms,
482
+ resetValidationAtoms,
483
+ operations,
484
+ } as FormAtoms<TFields, R>
485
+ }
package/src/Mode.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Form validation mode configuration.
3
+ *
4
+ * @since 1.0.0
5
+ */
6
+ import * as Duration from "effect/Duration"
7
+
8
+ /**
9
+ * Controls when field validation is triggered and whether form auto-submits.
10
+ *
11
+ * Simple modes (string):
12
+ * - `"onSubmit"`: Validation only runs when the form is submitted (default)
13
+ * - `"onBlur"`: Validation runs when a field loses focus
14
+ * - `"onChange"`: Validation runs on every value change (sync)
15
+ *
16
+ * Object modes (with options):
17
+ * - `{ onChange: { debounce, autoSubmit? } }`: Debounced validation, optional auto-submit
18
+ * - `{ onBlur: { autoSubmit: true } }`: Validate on blur, auto-submit when valid
19
+ *
20
+ * @since 1.0.0
21
+ * @category Models
22
+ */
23
+ export type FormMode =
24
+ | "onSubmit"
25
+ | "onBlur"
26
+ | "onChange"
27
+ | { readonly onChange: { readonly debounce: Duration.DurationInput; readonly autoSubmit?: false } }
28
+ | { readonly onBlur: { readonly autoSubmit: true } }
29
+ | { readonly onChange: { readonly debounce: Duration.DurationInput; readonly autoSubmit: true } }
30
+
31
+ /**
32
+ * Parsed form mode with resolved values.
33
+ *
34
+ * @since 1.0.0
35
+ * @category Models
36
+ */
37
+ export interface ParsedMode {
38
+ readonly validation: "onSubmit" | "onBlur" | "onChange"
39
+ readonly debounce: number | null
40
+ readonly autoSubmit: boolean
41
+ }
42
+
43
+ /**
44
+ * Parses a FormMode into a normalized ParsedMode.
45
+ *
46
+ * @since 1.0.0
47
+ * @category Parsing
48
+ */
49
+ export const parse = (mode: FormMode = "onSubmit"): ParsedMode => {
50
+ if (typeof mode === "string") {
51
+ return { validation: mode, debounce: null, autoSubmit: false }
52
+ }
53
+ if ("onBlur" in mode) {
54
+ return { validation: "onBlur", debounce: null, autoSubmit: true }
55
+ }
56
+ const debounceMs = Duration.toMillis(mode.onChange.debounce)
57
+ const autoSubmit = mode.onChange.autoSubmit === true
58
+ return { validation: "onChange", debounce: debounceMs, autoSubmit }
59
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Validation utilities for form error handling.
3
+ *
4
+ * @since 1.0.0
5
+ */
6
+ import * as Option from "effect/Option"
7
+ import * as ParseResult from "effect/ParseResult"
8
+ import { schemaPathToFieldPath } from "./internal/path.js"
9
+
10
+ /**
11
+ * Extracts the first error message from a ParseError.
12
+ *
13
+ * @since 1.0.0
14
+ * @category Error Handling
15
+ */
16
+ export const extractFirstError = (error: ParseResult.ParseError): Option.Option<string> => {
17
+ const issues = ParseResult.ArrayFormatter.formatErrorSync(error)
18
+ if (issues.length === 0) {
19
+ return Option.none()
20
+ }
21
+ return Option.some(issues[0].message)
22
+ }
23
+
24
+ /**
25
+ * Routes validation errors from a ParseError to a map of field paths to error messages.
26
+ * Used for cross-field validation where schema errors need to be displayed on specific fields.
27
+ *
28
+ * @since 1.0.0
29
+ * @category Error Handling
30
+ */
31
+ export const routeErrors = (error: ParseResult.ParseError): Map<string, string> => {
32
+ const result = new Map<string, string>()
33
+ const issues = ParseResult.ArrayFormatter.formatErrorSync(error)
34
+
35
+ for (const issue of issues) {
36
+ const fieldPath = schemaPathToFieldPath(issue.path)
37
+ if (fieldPath && !result.has(fieldPath)) {
38
+ result.set(fieldPath, issue.message)
39
+ }
40
+ }
41
+
42
+ return result
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @since 1.0.0
3
+ */
4
+ export * as Form from "./Form.js"
5
+
6
+ /**
7
+ * Atom infrastructure for form state management.
8
+ *
9
+ * This module provides the core atom infrastructure that framework adapters
10
+ * (React, Vue, Svelte, Solid) can use to build reactive form components.
11
+ *
12
+ * @since 1.0.0
13
+ */
14
+ export * as FormAtoms from "./FormAtoms.js"
15
+
16
+ /**
17
+ * Form validation mode configuration.
18
+ *
19
+ * @since 1.0.0
20
+ */
21
+ export * as Mode from "./Mode.js"
22
+
23
+ /**
24
+ * Validation utilities for form error handling.
25
+ *
26
+ * @since 1.0.0
27
+ */
28
+ export * as Validation from "./Validation.js"