@codeleap/form 6.2.3 → 6.8.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 (69) hide show
  1. package/dist/fields/bool.d.ts +17 -0
  2. package/dist/fields/bool.d.ts.map +1 -0
  3. package/dist/fields/date.d.ts +22 -0
  4. package/dist/fields/date.d.ts.map +1 -0
  5. package/dist/fields/file.d.ts +17 -0
  6. package/dist/fields/file.d.ts.map +1 -0
  7. package/dist/fields/group.d.ts +17 -0
  8. package/dist/fields/group.d.ts.map +1 -0
  9. package/dist/fields/index.d.ts +87 -0
  10. package/dist/fields/index.d.ts.map +1 -0
  11. package/dist/fields/list.d.ts +34 -0
  12. package/dist/fields/list.d.ts.map +1 -0
  13. package/dist/fields/number.d.ts +23 -0
  14. package/dist/fields/number.d.ts.map +1 -0
  15. package/dist/fields/selectable.d.ts +25 -0
  16. package/dist/fields/selectable.d.ts.map +1 -0
  17. package/dist/fields/text.d.ts +21 -0
  18. package/dist/fields/text.d.ts.map +1 -0
  19. package/dist/hooks/index.d.ts +3 -0
  20. package/dist/hooks/index.d.ts.map +1 -0
  21. package/dist/hooks/useField.d.ts +16 -0
  22. package/dist/hooks/useField.d.ts.map +1 -0
  23. package/dist/hooks/useValidate.d.ts +23 -0
  24. package/dist/hooks/useValidate.d.ts.map +1 -0
  25. package/dist/index.d.ts +6 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/lib/Field.d.ts +193 -0
  28. package/dist/lib/Field.d.ts.map +1 -0
  29. package/dist/lib/Form.d.ts +88 -0
  30. package/dist/lib/Form.d.ts.map +1 -0
  31. package/dist/lib/factories.d.ts +14 -0
  32. package/dist/lib/factories.d.ts.map +1 -0
  33. package/dist/lib/index.d.ts +3 -0
  34. package/dist/lib/index.d.ts.map +1 -0
  35. package/dist/lib/useFieldBinding.d.ts +14 -0
  36. package/dist/lib/useFieldBinding.d.ts.map +1 -0
  37. package/dist/types/field.d.ts +22 -0
  38. package/dist/types/field.d.ts.map +1 -0
  39. package/dist/types/form.d.ts +26 -0
  40. package/dist/types/form.d.ts.map +1 -0
  41. package/dist/types/globals.d.ts +15 -0
  42. package/dist/types/globals.d.ts.map +1 -0
  43. package/dist/types/index.d.ts +5 -0
  44. package/dist/types/index.d.ts.map +1 -0
  45. package/dist/types/validation.d.ts +9 -0
  46. package/dist/types/validation.d.ts.map +1 -0
  47. package/dist/validators/index.d.ts +2 -0
  48. package/dist/validators/index.d.ts.map +1 -0
  49. package/dist/validators/zod.d.ts +22 -0
  50. package/dist/validators/zod.d.ts.map +1 -0
  51. package/package.json +26 -10
  52. package/src/fields/bool.ts +6 -0
  53. package/src/fields/date.ts +9 -0
  54. package/src/fields/file.ts +7 -0
  55. package/src/fields/group.ts +6 -0
  56. package/src/fields/index.ts +17 -2
  57. package/src/fields/list.ts +66 -17
  58. package/src/fields/number.ts +9 -0
  59. package/src/fields/selectable.ts +9 -0
  60. package/src/fields/text.ts +8 -1
  61. package/src/hooks/useField.ts +13 -0
  62. package/src/hooks/useValidate.ts +16 -0
  63. package/src/lib/Field.ts +166 -22
  64. package/src/lib/Form.ts +98 -13
  65. package/src/lib/factories.tsx +8 -0
  66. package/src/lib/useFieldBinding.ts +13 -2
  67. package/src/types/globals.ts +6 -2
  68. package/src/validators/zod.ts +16 -1
  69. package/package.json.bak +0 -30
@@ -1,6 +1,19 @@
1
1
  import { useMemo } from "react"
2
2
  import type { Field } from "../lib"
3
3
 
4
+ /**
5
+ * Convenience hook for components that accept an optional `field` prop. When
6
+ * `field` is provided it is used directly; when it is absent (falsy), a
7
+ * temporary field created by `defaultField` is memoised for the component's
8
+ * lifetime.
9
+ *
10
+ * This lets components remain uncontrolled by default (using their own
11
+ * internal field) while still accepting external control when a `field` prop
12
+ * is supplied — without conditional hook calls.
13
+ *
14
+ * `params` are forwarded to `field.use()` in either branch, so imperative
15
+ * ref bindings work regardless of which field is active.
16
+ */
4
17
  export function useField<V, T extends Field<V, any, any>>(field: T, params: Parameters<T['use']>, defaultField: () => T): ReturnType<T['use']> {
5
18
  if (field) {
6
19
  return field.use(params?.[0], params?.[1]) as ReturnType<T['use']>
@@ -2,6 +2,22 @@ import { useCallback, useRef, useState } from 'react'
2
2
  import { Validator } from '../types'
3
3
  import { ValidationError } from '../lib/Field'
4
4
 
5
+ /**
6
+ * Standalone validation hook for values managed outside a `Field` instance.
7
+ * Useful when you have a raw state value and a validator function but do not
8
+ * want to create a full `Field` object.
9
+ *
10
+ * Error display follows the same blur policy as `Field.useValidation`: if
11
+ * `value` is `undefined` on the first render (`startedUnset`), the error is
12
+ * hidden until the user has blurred the input. Call the returned
13
+ * `onInputBlurred` handler on the input's blur event to trigger visibility.
14
+ *
15
+ * `message` is resolved from `readableError` first, then from the first
16
+ * element's `.message` when `error` is an array.
17
+ *
18
+ * A {@link ValidationError} thrown by `providedValidate` is caught and
19
+ * normalised; other exceptions propagate.
20
+ */
5
21
  export const useValidate = <T, V extends Validator<T, any, any>>(value: T, providedValidate: V) => {
6
22
  const [hasBlurred, setHasBlurred] = useState(false)
7
23
 
package/src/lib/Field.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { FieldState, FieldOptions, FieldMeasureResult } from '../types/field';
2
2
  import { ValidationResult, Validator } from '../types/validation';
3
- import { IFieldRef, IFieldProps } from '../types/globals';
4
- import { atom } from 'nanostores'
3
+ import { IFieldRef, IFieldProps, PropTransformer } from '../types/globals';
4
+ import { Atom, atom, WritableAtom } from 'nanostores'
5
5
  import { AnyRecord, SecondToLastArguments, TypeGuards } from '@codeleap/types';
6
6
 
7
7
  import { useStore } from '@nanostores/react'
@@ -9,6 +9,14 @@ import { useFieldBinding } from './useFieldBinding';
9
9
  import { createRef, useRef, useState } from 'react';
10
10
  import { logger } from '@codeleap/logger';
11
11
 
12
+ /**
13
+ * Thrown inside a validator function to signal a structured validation failure
14
+ * without propagating as an unhandled exception. The `data` payload is forwarded
15
+ * verbatim as the `error` field of the resulting {@link ValidationResult}.
16
+ *
17
+ * Throw this instead of returning `{ isValid: false }` when you need to exit
18
+ * validation from a nested helper that cannot easily return a value.
19
+ */
12
20
  export class ValidationError extends Error {
13
21
  data: any
14
22
 
@@ -20,27 +28,62 @@ export class ValidationError extends Error {
20
28
 
21
29
 
22
30
 
31
+ /**
32
+ * Base class for all form fields. Owns the nanostores atom that holds the
33
+ * field's current value, runs synchronous validation on demand, and exposes
34
+ * React hooks (`use`, `useValue`, `useValidation`) that components call to
35
+ * subscribe to reactive updates.
36
+ *
37
+ * **Validation lifecycle**
38
+ * Validation is always synchronous and called eagerly — there is no deferred
39
+ * or debounced step. `validate()` runs against the current atom value each
40
+ * time it is called, so repeated calls are cheap but not memoised. Error
41
+ * visibility is decoupled from validity: errors are hidden until
42
+ * `revealError()` is called (or the field has been blurred while starting
43
+ * with an undefined value), allowing UX to control when messages appear.
44
+ *
45
+ * **Initialization order constraint**
46
+ * The constructor calls `loadState()` synchronously. If `defaultValue` is a
47
+ * Promise or a function that returns a Promise, the atom is initialised with
48
+ * `undefined` immediately and the resolved value is set asynchronously once
49
+ * the promise settles. Do not read `initialValue` before the promise resolves.
50
+ *
51
+ * **Platform methods**
52
+ * `measurePosition`, `scrollTo`, and `getPadding` delegate to static method
53
+ * slots (`methodMeasurePosition`, `methodScrollTo`, `methodGetPadding`) that
54
+ * must be assigned by the platform layer (web or mobile) before use; calling
55
+ * them without an implementation throws.
56
+ *
57
+ * **Prop transformers**
58
+ * `props()` pipes the field's properties through every registered transformer
59
+ * in insertion order. Transformers are global and shared across all field
60
+ * instances via `Field.transformers`.
61
+ */
23
62
  export class Field<
24
- T,
63
+ T,
25
64
  Validate extends Validator<T,any,any>,
26
65
  Result = Validate extends Validator<T, infer R, any> ? R : never,
27
66
  Err = Validate extends Validator<T, any, infer E> ? E : never
28
67
  > {
29
- _type: string
68
+ _type!: string
30
69
 
31
- deep: boolean
70
+ deep!: boolean
32
71
 
33
- state: FieldState<T>
72
+ state!: FieldState<T>
73
+
74
+ errorRevealed: WritableAtom<boolean>
34
75
 
35
76
  properties: AnyRecord = {}
36
77
 
78
+ static transformers: Map<string, PropTransformer> = new Map()
79
+
37
80
  options: FieldOptions<T, Validate>
38
81
 
39
- ref?: React.RefObject<IFieldRef<T>>
82
+ ref?: React.RefObject<IFieldRef<T> | null>
40
83
 
41
- __validationRes: ValidationResult<Result, Err>
84
+ __validationRes!: ValidationResult<Result, Err>
42
85
 
43
- private initialValue: T
86
+ private initialValue!: T
44
87
 
45
88
  static enableLogs = false
46
89
 
@@ -60,14 +103,40 @@ export class Field<
60
103
  throw new Error('Field.getPadding not implemented')
61
104
  }
62
105
 
106
+ /**
107
+ * Marks the field's error as visible. Typically called by `Form.validate`
108
+ * when `revealErrors: true` is passed, or imperatively after a failed submit.
109
+ * Has no effect on the underlying validity — only on whether the error
110
+ * message is shown to the user.
111
+ */
112
+ revealError(){
113
+ this.errorRevealed.set(true)
114
+ }
115
+
116
+ /**
117
+ * Resets error visibility to hidden without changing the field's value or
118
+ * validity state. Useful when clearing a form section programmatically
119
+ * while preserving the current value.
120
+ */
121
+ hideError(){
122
+ this.errorRevealed.set(false)
123
+ }
124
+
125
+ get isErrorRevealed(){
126
+ return this.errorRevealed.get()
127
+ }
128
+
63
129
  constructor(options: FieldOptions<T, Validate>) {
64
130
  this.options = options
65
131
  this.ref = createRef()
66
132
  this.loadState()
133
+ this.errorRevealed = atom(false)
67
134
 
68
135
  this.setValue = this.setValue.bind(this)
69
136
  this.use = this.use.bind(this)
70
137
  this.useBinding = this.useBinding.bind(this)
138
+ this.revealError = this.revealError.bind(this)
139
+ this.hideError = this.hideError.bind(this)
71
140
 
72
141
  this.properties = this.toInternalProperties(options)
73
142
  }
@@ -89,12 +158,24 @@ export class Field<
89
158
  private toInternalProperties(options: FieldOptions<T, Validate>) {
90
159
  const internalKeys = new Set(['name', 'defaultValue', 'state', 'validate', 'loader', 'onValueChange'])
91
160
 
161
+ const values = {
162
+ name: this.name,
163
+ ...Object.fromEntries(Object.entries(options).filter(([key]) => !internalKeys.has(key)))
164
+ }
165
+
92
166
  return Object.assign(
93
167
  { field: this },
94
- Object.fromEntries(Object.entries(options).filter(([key]) => !internalKeys.has(key)))
168
+ values
169
+
95
170
  )
96
171
  }
97
172
 
173
+ /**
174
+ * Replaces the field's internal atom with a slice of the owning `Form`'s
175
+ * global state atom and migrates the current value into it. Called
176
+ * automatically by `Form.attachState()` during construction — do not call
177
+ * this manually unless you are building a custom form container.
178
+ */
98
179
  attach(to: FieldState<T>) {
99
180
  const val = this.value
100
181
 
@@ -116,8 +197,19 @@ export class Field<
116
197
 
117
198
  resetValue() {
118
199
  this.setValue(this.initialValue)
200
+ this.errorRevealed.set(false)
119
201
  }
120
202
 
203
+ /**
204
+ * Primary React hook for consuming a field inside a component. Subscribes to
205
+ * the field's value atom and validation state, and optionally wires an
206
+ * imperative ref handle via `useFieldBinding`. Must be called
207
+ * unconditionally at the component's top level.
208
+ *
209
+ * The `changed` flag compares the current value to the value captured at
210
+ * field construction time (or after the last `resetValue`), not to any
211
+ * previous render.
212
+ */
121
213
  use(impl?: Partial<IFieldRef<T>>, deps?: any[]){
122
214
  const value = this.useValue()
123
215
 
@@ -151,24 +243,24 @@ export class Field<
151
243
 
152
244
  // If we make this async, the js engine will not delay further execution while the funcion is not done. This way we wait until we know wheter there's a promise or not
153
245
  loadState(){
154
- let defaultValuePromise: Promise<T> = undefined
155
- let defaultValue:T = undefined
246
+ let defaultValuePromise: Promise<T> | undefined = undefined
247
+ let defaultValue: T = undefined as T
156
248
 
157
249
  if(TypeGuards.isFunction(this.options.defaultValue)) {
158
250
  const v = this.options.defaultValue()
159
251
 
160
252
  if(TypeGuards.isPromise(v)) {
161
- defaultValuePromise = v
253
+ defaultValuePromise = v as Promise<T>
162
254
  } else {
163
- defaultValue = v
255
+ defaultValue = v as T
164
256
  }
165
257
  } else {
166
258
  const v = this.options?.defaultValue
167
259
 
168
260
  if(TypeGuards.isPromise(v)) {
169
- defaultValuePromise = v
261
+ defaultValuePromise = v as Promise<T>
170
262
  } else {
171
- defaultValue = v
263
+ defaultValue = v as T
172
264
  }
173
265
  }
174
266
 
@@ -214,6 +306,15 @@ export class Field<
214
306
  return logArgs
215
307
  }
216
308
 
309
+ /**
310
+ * Runs the field's validator synchronously against `value` (or the current
311
+ * atom value when omitted). A {@link ValidationError} thrown inside the
312
+ * validator is caught and converted to `{ isValid: false, error: e.data }`;
313
+ * any other exception is re-thrown.
314
+ *
315
+ * This method is called on every render inside `useValidation` — keep
316
+ * validators fast and free of side effects.
317
+ */
217
318
  validate(value?: any): ValidationResult<Result, Err> {
218
319
 
219
320
  const validate = this.options.validate
@@ -221,8 +322,8 @@ export class Field<
221
322
  const valueToCheck = TypeGuards.isUndefined(value) ? this.state.get() : this.toInternalValue(value)
222
323
 
223
324
  try {
224
- const result = validate(valueToCheck, {})
225
-
325
+ const result = validate!(valueToCheck, {})
326
+
226
327
  return result
227
328
 
228
329
  } catch(e) {
@@ -239,8 +340,22 @@ export class Field<
239
340
 
240
341
  }
241
342
 
343
+ /**
344
+ * React hook that returns reactive validation state for the field's current
345
+ * value. Error display policy:
346
+ * - If the field started with an `undefined` value (`startedUnset`), the
347
+ * error is hidden until the user has blurred the input **or** until
348
+ * `revealError()` has been called.
349
+ * - If the field started with a defined value, the error is shown
350
+ * immediately whenever the field is invalid.
351
+ *
352
+ * `message` is resolved from `readableError` first, falling back to the
353
+ * first element's `.message` when `error` is an array.
354
+ */
242
355
  useValidation() {
243
356
  const value = this.useValue()
357
+
358
+ const revealed = useStore(this.errorRevealed)
244
359
 
245
360
  const isUnset = typeof value === 'undefined'
246
361
 
@@ -256,14 +371,15 @@ export class Field<
256
371
 
257
372
  const hasChanged = this.initialValue != value
258
373
 
259
- const message = validation.readableError ?? validation.error?.[0]?.message
374
+ const message = validation.readableError ?? (Array.isArray(validation.error) ? validation.error[0]?.message : undefined)
260
375
 
261
376
  const errorDisplayRequiresBlur = startedUnset
262
377
 
263
378
 
264
379
  const [hasBlurred, setHasBlurred] = useState(false)
265
380
 
266
- const showError = isInvalid && (errorDisplayRequiresBlur ? hasBlurred : true)
381
+ const showError = isInvalid && (
382
+ errorDisplayRequiresBlur ? revealed || hasBlurred : true)
267
383
 
268
384
 
269
385
 
@@ -286,7 +402,7 @@ export class Field<
286
402
  }
287
403
 
288
404
  log(level = 'log', ...args: any[]){
289
- if (Field.enableLogs) logger[level](...this.formatLog(...args))
405
+ if (Field.enableLogs) (logger as unknown as Record<string, (...args: any[]) => void>)[level](...this.formatLog(...args))
290
406
  }
291
407
 
292
408
  toInternalValue(v: any) {
@@ -297,8 +413,36 @@ export class Field<
297
413
  return v as any
298
414
  }
299
415
 
416
+ /**
417
+ * Registers a global prop transformer under `key`. Transformers are applied
418
+ * in insertion order by `props()`. Re-registering an existing key replaces
419
+ * the previous function.
420
+ */
421
+ static attachTransformer(key: string, fn: PropTransformer) {
422
+ Field.transformers.set(key, fn)
423
+ }
424
+
425
+ /**
426
+ * Removes the transformer registered under `key`. Idempotent — no-op if the
427
+ * key is not present.
428
+ */
429
+ static detachTransformer(key: string) {
430
+ Field.transformers.delete(key)
431
+ }
432
+
433
+ /**
434
+ * Returns the field's properties after running them through every registered
435
+ * global transformer. The raw `properties` object is built from `options`
436
+ * with internal keys (`name`, `defaultValue`, `state`, `validate`, `loader`,
437
+ * `onValueChange`) stripped out, plus a `field` reference to `this`.
438
+ * Use this when passing field metadata to a component that does not use the
439
+ * `use()` hook directly.
440
+ */
300
441
  props(): IFieldProps {
301
- return this.properties
442
+ return Array.from(Field.transformers.values()).reduce<AnyRecord>(
443
+ (acc, transformer) => transformer(acc),
444
+ this.properties,
445
+ ) as IFieldProps
302
446
  }
303
447
 
304
448
  measurePosition<T>(wrapperRef: T): Promise<FieldMeasureResult> {
package/src/lib/Form.ts CHANGED
@@ -2,12 +2,13 @@ import { createStateSlice, GlobalState, globalState } from "@codeleap/store"
2
2
  import { TypeGuards } from "@codeleap/types"
3
3
  import { DependencyList, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
4
4
  import { FieldPaths, FieldPropertyTuples, FieldTuples, FormDef, FormValues, PropertyForKeys, ValidationResult } from "../types"
5
+ import { useUnmount } from "@codeleap/hooks"
5
6
 
6
7
 
7
8
 
8
9
 
9
10
  function buildState<T extends FormDef>(def: T) {
10
- const stateArg = {}
11
+ const stateArg: Record<string, unknown> = {}
11
12
 
12
13
  for(const [name, field] of Object.entries(def)) {
13
14
  stateArg[name] = field.value
@@ -21,6 +22,30 @@ type FormSelector<T extends FormDef, S> = (form: Form<T>) => S
21
22
 
22
23
 
23
24
 
25
+ /**
26
+ * Container that owns the shared nanostores `GlobalState` atom for a group of
27
+ * fields and wires each field's individual atom to a slice of that shared
28
+ * state. This ensures a single source of truth: reading `form.values` always
29
+ * reflects the live atom values of every field.
30
+ *
31
+ * **Initialization order**
32
+ * The constructor calls `attachState()` synchronously, which replaces each
33
+ * field's own atom with a derived slice and sets the field's `name` from its
34
+ * object key in the shape. Fields must therefore be fully constructed before
35
+ * being passed to `Form`.
36
+ *
37
+ * **React usage**
38
+ * - `use(selector)` — mounts the form in a component, subscribes to state
39
+ * changes via `selector`, and resets all field values on unmount. Prefer
40
+ * this for full-page forms.
41
+ * - `useShared(selector)` — subscribes without the unmount-reset side-effect.
42
+ * Use when the form outlives the component (e.g. a global singleton).
43
+ *
44
+ * **Validation**
45
+ * `validate()` runs every field's synchronous validator and returns a map of
46
+ * results keyed by field name. Pass `revealErrors: true` to simultaneously
47
+ * flip `errorRevealed` on every field, triggering error display in the UI.
48
+ */
24
49
  class Form<T extends FormDef> {
25
50
  id: string
26
51
  fields: T
@@ -36,30 +61,45 @@ class Form<T extends FormDef> {
36
61
  )
37
62
 
38
63
  this.attachState()
39
-
64
+ this.use = this.use.bind(this)
65
+ this.useShared = this.useShared.bind(this)
66
+ this.useReset = this.useReset.bind(this)
40
67
  }
41
68
 
42
69
  get values(){
43
70
  return this.state.get()
44
71
  }
45
72
 
73
+ get isChanged(){
74
+ return this.changed()
75
+ }
76
+
77
+
78
+ changed(){
79
+ for(const [fieldName, field] of Object.entries(this.fields)){
80
+ if(field.changed()) return true
81
+ }
82
+
83
+ return false
84
+ }
85
+
46
86
  get isValid(){
47
87
  const res = this.validate()
48
88
 
49
- return Object.values(res).every((result: ValidationResult<any, any>) => result.isValid)
89
+ return (Object.values(res) as ValidationResult<any, any>[]).every((result) => result.isValid)
50
90
  }
51
91
 
52
92
  slice<K extends FieldPaths<T>>(field: K) {
53
93
 
54
94
  const fieldSlice = createStateSlice(
55
- this.state,
56
- (v) => v[field],
95
+ this.state,
96
+ (v) => (v as Record<string, unknown>)[field as string],
57
97
  (value) => {
58
98
  return {
59
99
  [field]: value
60
100
  } as FormValues<T>
61
101
  }
62
-
102
+
63
103
  )
64
104
 
65
105
  return fieldSlice
@@ -80,6 +120,11 @@ class Form<T extends FormDef> {
80
120
  return results
81
121
  }
82
122
 
123
+ /**
124
+ * Bulk-sets field values from a partial record. Boolean fields require an
125
+ * explicit `boolean` value; all other fields skip `undefined` and falsy
126
+ * values. Use `resetValues()` to restore all fields to their initial values.
127
+ */
83
128
  setValues(values: Partial<FormValues<T>>) {
84
129
  this.iterFields(([name, field]) => {
85
130
  const value = values?.[name]
@@ -104,6 +149,11 @@ class Form<T extends FormDef> {
104
149
  })
105
150
  }
106
151
 
152
+ /**
153
+ * Returns the first field (in definition order) that fails validation, along
154
+ * with its `ValidationResult`. Returns `undefined` when all fields are
155
+ * valid. Useful for scrolling to the first error on submit.
156
+ */
107
157
  firstInvalid() {
108
158
  for(const [fieldName, field] of Object.entries(this.fields)){
109
159
  const validation = field.validate()
@@ -115,7 +165,9 @@ class Form<T extends FormDef> {
115
165
  }
116
166
  }
117
167
 
118
- validate<Fields extends FieldPaths<T>[] = FieldPaths<T>[]>(fields?: Fields): PropertyForKeys<T, Fields[number], '__validationRes'> {
168
+ validate<Fields extends FieldPaths<T>[] = FieldPaths<T>[]>(options?: { fields?: Fields, revealErrors?: boolean }): PropertyForKeys<T, Fields[number], '__validationRes'> {
169
+
170
+ const { fields, revealErrors } = options ?? {}
119
171
 
120
172
  const validateFields = fields ?? Object.keys(this.fields)
121
173
 
@@ -126,14 +178,25 @@ class Form<T extends FormDef> {
126
178
  })
127
179
 
128
180
  const resultMap = Object.fromEntries(
129
- results.filter(v => !TypeGuards.isNil(v))
181
+ results.filter((v): v is FieldPropertyTuples<T, '__validationRes'> => !TypeGuards.isNil(v))
130
182
  )
131
183
 
184
+ if(revealErrors){
185
+ this.iterFields(([_,field]) => {
186
+ field.revealError()
187
+ })
188
+ }
189
+
132
190
  return resultMap as unknown as PropertyForKeys<T, Fields[number], '__validationRes'>
133
191
  }
134
192
 
135
193
 
136
194
 
195
+ /**
196
+ * Returns the transformed props for `field` via `Field.props()`. Throws if
197
+ * the field key does not exist in this form's shape, making typos a runtime
198
+ * error rather than a silent no-op.
199
+ */
137
200
  register(field: FieldPaths<T>) {
138
201
  if(!this.fields[field]){
139
202
  throw new Error(`Field "${field}" not found in "${this.id}" form`)
@@ -141,13 +204,20 @@ class Form<T extends FormDef> {
141
204
  return this.fields[field].props()
142
205
  }
143
206
 
144
- useSetValues(formValues?: Partial<FormValues<T>>, deps: DependencyList = []) {
145
- useLayoutEffect(() => {
146
- if (formValues) this.setValues(formValues)
147
- }, deps)
207
+ use<Selected>(selector: FormSelector<T, Selected>): Selected {
208
+ const value = this.useShared(selector)
209
+
210
+ this.useReset()
211
+
212
+ return value
148
213
  }
149
214
 
150
- use<Selected>(selector: FormSelector<T, Selected>): Selected {
215
+ useReset(){
216
+ useUnmount(() => {
217
+ this.resetValues()
218
+ })
219
+ }
220
+ useShared<Selected>(selector: FormSelector<T, Selected>): Selected {
151
221
  const [selected, setSelected] = useState(() => selector(this))
152
222
 
153
223
  const reselect = useCallback(() => {
@@ -167,6 +237,15 @@ class Form<T extends FormDef> {
167
237
  }
168
238
 
169
239
 
240
+ /**
241
+ * Creates a `Form` instance that is stable for the lifetime of the component
242
+ * (memoised on `name`). Use this when the form is local to a single mounted
243
+ * component. For forms that must survive unmounts (e.g. multi-step flows),
244
+ * construct the `Form` outside React via `form()`.
245
+ *
246
+ * Note: `def` is only read on the first render — changing it after mount has
247
+ * no effect.
248
+ */
170
249
  export function useForm<T extends FormDef>(name: string, def: T) {
171
250
  const form = useMemo(() => {
172
251
  return new Form(name, def)
@@ -176,6 +255,12 @@ export function useForm<T extends FormDef>(name: string, def: T) {
176
255
  return form
177
256
  }
178
257
 
258
+ /**
259
+ * Constructs a `Form` outside of React — suitable for module-level singletons,
260
+ * server-side construction, or multi-step flows where the form must outlive
261
+ * any individual component. Values are **not** automatically reset on unmount;
262
+ * call `resetValues()` explicitly when needed.
263
+ */
179
264
  export function form<Def extends FormDef>(...args: ConstructorParameters<typeof Form<Def>>) {
180
265
  return new Form(...args)
181
266
  }
@@ -4,6 +4,14 @@ import { Field } from "./Field"
4
4
 
5
5
  type FieldBuilder<T, Validate extends Validator<any,any,any>> = typeof Field<T, Validate>
6
6
 
7
+ /**
8
+ * Wraps a `Field` subclass constructor in a factory function, eliminating the
9
+ * need to call `new` at the call site and improving inference of the
10
+ * `validate` option's generic type. The returned factory accepts the same
11
+ * options as the class constructor and produces a fully typed `Field` instance.
12
+ *
13
+ * All entries in the `fields` namespace are built with this helper.
14
+ */
7
15
  export function fieldFactory<
8
16
  T extends FieldBuilder<any,any>,
9
17
  Value = T extends FieldBuilder<infer V, any> ? V : never,
@@ -1,10 +1,21 @@
1
1
  import { useImperativeHandle } from "react"
2
2
  import { IFieldRef } from "../types"
3
3
 
4
- export function useFieldBinding<T>(ref: React.Ref<IFieldRef<T>>, impl: Partial<IFieldRef<T>>, deps = []){
4
+ /**
5
+ * Attaches an imperative handle to `ref` via `useImperativeHandle`. Every
6
+ * method in {@link IFieldRef} is given a default implementation that throws
7
+ * `"ref.<method> not implemented"`, so callers get a clear error rather than a
8
+ * silent no-op when a method is missing from `impl`.
9
+ *
10
+ * `impl` is a partial override — only supply the methods your component
11
+ * actually supports. Unimplemented methods remain as throwing stubs.
12
+ * `deps` is forwarded directly to `useImperativeHandle`; include anything
13
+ * `impl` closes over that can change between renders.
14
+ */
15
+ export function useFieldBinding<T>(ref: React.Ref<IFieldRef<T> | null> | undefined, impl: Partial<IFieldRef<T>>, deps: React.DependencyList = []){
5
16
 
6
17
  const notImplemented = (method: string) => {
7
- throw new Error(`ref.${method} not implemented for ${this._type} field`)
18
+ throw new Error(`ref.${method} not implemented`)
8
19
  }
9
20
 
10
21
 
@@ -1,3 +1,5 @@
1
+ import { AnyRecord } from '@codeleap/types'
2
+
1
3
  export interface IFieldRef<T> {
2
4
  getValue(): T
3
5
  scrollIntoView(): Promise<void>
@@ -11,5 +13,7 @@ export interface IFieldRef<T> {
11
13
 
12
14
 
13
15
  export interface IFieldProps {
14
-
15
- }
16
+
17
+ }
18
+
19
+ export type PropTransformer = (props: AnyRecord) => AnyRecord
@@ -4,8 +4,16 @@ import { TypeGuards } from '@codeleap/types'
4
4
 
5
5
  type ZodValidationResult<T extends z.ZodType> = ValidationResult<z.infer<T>, z.ZodError['issues']>
6
6
 
7
+ /**
8
+ * Adapts a Zod schema into the `Validator` contract expected by `Field` and
9
+ * `useValidate`. Uses `safeParse` internally so Zod errors are captured as
10
+ * `{ isValid: false, error: ZodIssue[] }` rather than thrown exceptions.
11
+ *
12
+ * The returned function is synchronous — async Zod schemas (`z.promise`,
13
+ * `.parseAsync`) are not supported.
14
+ */
7
15
  export function zodValidator<T extends z.ZodType>(model: T) {
8
- return (value): ZodValidationResult<T> => {
16
+ return (value: unknown): ZodValidationResult<T> => {
9
17
  const result = model.safeParse(value)
10
18
 
11
19
  return {
@@ -21,6 +29,13 @@ const isZodIssue = (val: any): val is ZodIssue => {
21
29
  return ['code','expected','received','path','message'].every(x => x in val)
22
30
  }
23
31
 
32
+ /**
33
+ * Type guard that narrows a `ValidationResult`-shaped value to one produced by
34
+ * `zodValidator`. A valid result must have `result` present; an invalid result
35
+ * must have `error` as an array of `ZodIssue` objects. Use this to
36
+ * distinguish Zod results from results produced by custom validators when
37
+ * handling errors generically.
38
+ */
24
39
  export function isZodValidationResult(val: any): val is ZodValidationResult<any> {
25
40
  const isValidABoolean = TypeGuards.isBoolean(val.isValid)
26
41