@codeleap/form 6.3.0 → 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.
- package/dist/fields/bool.d.ts +17 -0
- package/dist/fields/bool.d.ts.map +1 -0
- package/dist/fields/date.d.ts +22 -0
- package/dist/fields/date.d.ts.map +1 -0
- package/dist/fields/file.d.ts +17 -0
- package/dist/fields/file.d.ts.map +1 -0
- package/dist/fields/group.d.ts +17 -0
- package/dist/fields/group.d.ts.map +1 -0
- package/dist/fields/index.d.ts +87 -0
- package/dist/fields/index.d.ts.map +1 -0
- package/dist/fields/list.d.ts +34 -0
- package/dist/fields/list.d.ts.map +1 -0
- package/dist/fields/number.d.ts +23 -0
- package/dist/fields/number.d.ts.map +1 -0
- package/dist/fields/selectable.d.ts +25 -0
- package/dist/fields/selectable.d.ts.map +1 -0
- package/dist/fields/text.d.ts +21 -0
- package/dist/fields/text.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +3 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/useField.d.ts +16 -0
- package/dist/hooks/useField.d.ts.map +1 -0
- package/dist/hooks/useValidate.d.ts +23 -0
- package/dist/hooks/useValidate.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/lib/Field.d.ts +193 -0
- package/dist/lib/Field.d.ts.map +1 -0
- package/dist/lib/Form.d.ts +88 -0
- package/dist/lib/Form.d.ts.map +1 -0
- package/dist/lib/factories.d.ts +14 -0
- package/dist/lib/factories.d.ts.map +1 -0
- package/dist/lib/index.d.ts +3 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/useFieldBinding.d.ts +14 -0
- package/dist/lib/useFieldBinding.d.ts.map +1 -0
- package/dist/types/field.d.ts +22 -0
- package/dist/types/field.d.ts.map +1 -0
- package/dist/types/form.d.ts +26 -0
- package/dist/types/form.d.ts.map +1 -0
- package/dist/types/globals.d.ts +15 -0
- package/dist/types/globals.d.ts.map +1 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/validation.d.ts +9 -0
- package/dist/types/validation.d.ts.map +1 -0
- package/dist/validators/index.d.ts +2 -0
- package/dist/validators/index.d.ts.map +1 -0
- package/dist/validators/zod.d.ts +22 -0
- package/dist/validators/zod.d.ts.map +1 -0
- package/package.json +26 -10
- package/src/fields/bool.ts +6 -0
- package/src/fields/date.ts +9 -0
- package/src/fields/file.ts +7 -0
- package/src/fields/group.ts +6 -0
- package/src/fields/index.ts +17 -2
- package/src/fields/list.ts +66 -17
- package/src/fields/number.ts +9 -0
- package/src/fields/selectable.ts +9 -0
- package/src/fields/text.ts +8 -1
- package/src/hooks/useField.ts +13 -0
- package/src/hooks/useValidate.ts +16 -0
- package/src/lib/Field.ts +166 -22
- package/src/lib/Form.ts +98 -13
- package/src/lib/factories.tsx +8 -0
- package/src/lib/useFieldBinding.ts +13 -2
- package/src/types/globals.ts +6 -2
- package/src/validators/zod.ts +16 -1
- package/package.json.bak +0 -30
package/src/hooks/useField.ts
CHANGED
|
@@ -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']>
|
package/src/hooks/useValidate.ts
CHANGED
|
@@ -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
|
|
68
|
+
_type!: string
|
|
30
69
|
|
|
31
|
-
deep
|
|
70
|
+
deep!: boolean
|
|
32
71
|
|
|
33
|
-
state
|
|
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
|
|
84
|
+
__validationRes!: ValidationResult<Result, Err>
|
|
42
85
|
|
|
43
|
-
private initialValue
|
|
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
|
-
|
|
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
|
|
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 && (
|
|
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
|
|
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)
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/factories.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
18
|
+
throw new Error(`ref.${method} not implemented`)
|
|
8
19
|
}
|
|
9
20
|
|
|
10
21
|
|
package/src/types/globals.ts
CHANGED
|
@@ -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
|
package/src/validators/zod.ts
CHANGED
|
@@ -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
|
|