@kontsedal/olas-core 0.0.6 → 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.
- package/dist/index.cjs +0 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +109 -27
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +109 -27
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +0 -0
- package/dist/index.mjs.map +1 -1
- package/dist/{root-B6pNq75w.mjs → root-Byq-QYTp.mjs} +1465 -347
- package/dist/root-Byq-QYTp.mjs.map +1 -0
- package/dist/{root-CFgYhBd-.cjs → root-CPSL5kpR.cjs} +1470 -346
- package/dist/root-CPSL5kpR.cjs.map +1 -0
- package/dist/testing.cjs +5 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +3 -2
- package/dist/testing.d.cts.map +1 -1
- package/dist/testing.d.mts +3 -2
- package/dist/testing.d.mts.map +1 -1
- package/dist/testing.mjs +5 -2
- package/dist/testing.mjs.map +1 -1
- package/dist/{types-Bmi04hDI.d.mts → types-Brp-4WaZ.d.mts} +233 -23
- package/dist/types-Brp-4WaZ.d.mts.map +1 -0
- package/dist/{types-BsZMPPOE.d.cts → types-DzDIypPo.d.cts} +233 -23
- package/dist/types-DzDIypPo.d.cts.map +1 -0
- package/package.json +4 -1
- package/src/controller/instance.ts +345 -94
- package/src/controller/root.ts +52 -30
- package/src/controller/types.ts +55 -4
- package/src/emitter.ts +8 -2
- package/src/errors.ts +39 -3
- package/src/forms/field.ts +219 -25
- package/src/forms/form-types.ts +16 -3
- package/src/forms/form.ts +360 -38
- package/src/forms/index.ts +3 -1
- package/src/forms/types.ts +27 -1
- package/src/forms/validators.ts +45 -11
- package/src/index.ts +13 -7
- package/src/query/client.ts +237 -58
- package/src/query/define.ts +0 -0
- package/src/query/entry.ts +259 -15
- package/src/query/focus-online.ts +53 -11
- package/src/query/infinite.ts +351 -47
- package/src/query/keys.ts +33 -2
- package/src/query/local.ts +3 -0
- package/src/query/mutation.ts +27 -6
- package/src/query/plugin.ts +43 -4
- package/src/query/types.ts +76 -6
- package/src/query/use.ts +53 -10
- package/src/selection.ts +49 -12
- package/src/signals/readonly.ts +3 -0
- package/src/signals/runtime.ts +27 -0
- package/src/signals/types.ts +10 -0
- package/src/testing.ts +9 -0
- package/src/timing/debounced.ts +106 -23
- package/src/timing/index.ts +1 -1
- package/src/timing/throttled.ts +85 -28
- package/src/utils.ts +15 -2
- package/dist/root-B6pNq75w.mjs.map +0 -1
- package/dist/root-CFgYhBd-.cjs.map +0 -1
- package/dist/types-Bmi04hDI.d.mts.map +0 -1
- package/dist/types-BsZMPPOE.d.cts.map +0 -1
package/src/forms/field.ts
CHANGED
|
@@ -10,7 +10,55 @@ import {
|
|
|
10
10
|
signal,
|
|
11
11
|
} from '../signals'
|
|
12
12
|
import { isAbortError } from '../utils'
|
|
13
|
-
import type { Validator } from './types'
|
|
13
|
+
import type { Validator, ValidatorResult } from './types'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Flatten a single validator result to plain message strings for a leaf field.
|
|
17
|
+
* A leaf has no descendants, so a `FormIssue[]`'s paths don't route anywhere —
|
|
18
|
+
* every issue's `message` applies to this field. `null` → no messages.
|
|
19
|
+
*/
|
|
20
|
+
function messagesFromResult(result: ValidatorResult): string[] {
|
|
21
|
+
if (result == null) return []
|
|
22
|
+
if (typeof result === 'string') return [result]
|
|
23
|
+
return result.map((issue) => issue.message)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Structural equality used by `Field.set` to decide whether a write returns
|
|
28
|
+
* the field to its initial value (clearing `isDirty`). Cheap path for
|
|
29
|
+
* primitives + `Object.is`; deep walk for arrays and plain objects. Class
|
|
30
|
+
* instances, Map, Set, Date fall back to reference identity — same trade-off
|
|
31
|
+
* `structural-share.ts` makes for cache data.
|
|
32
|
+
*/
|
|
33
|
+
function isStructurallyEqual(a: unknown, b: unknown): boolean {
|
|
34
|
+
if (Object.is(a, b)) return true
|
|
35
|
+
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
|
|
36
|
+
if (Array.isArray(a)) {
|
|
37
|
+
if (!Array.isArray(b) || a.length !== b.length) return false
|
|
38
|
+
for (let i = 0; i < a.length; i++) {
|
|
39
|
+
if (!isStructurallyEqual(a[i], b[i])) return false
|
|
40
|
+
}
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(b)) return false
|
|
44
|
+
const protoA = Object.getPrototypeOf(a)
|
|
45
|
+
const protoB = Object.getPrototypeOf(b)
|
|
46
|
+
// Plain-object guard only — class instances aren't safe to walk by keys.
|
|
47
|
+
if (protoA !== Object.prototype && protoA !== null) return false
|
|
48
|
+
if (protoB !== Object.prototype && protoB !== null) return false
|
|
49
|
+
const keysA = Object.keys(a as Record<string, unknown>)
|
|
50
|
+
const keysB = Object.keys(b as Record<string, unknown>)
|
|
51
|
+
if (keysA.length !== keysB.length) return false
|
|
52
|
+
for (const k of keysA) {
|
|
53
|
+
if (!Object.hasOwn(b as object, k)) return false
|
|
54
|
+
if (
|
|
55
|
+
!isStructurallyEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])
|
|
56
|
+
) {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
14
62
|
|
|
15
63
|
/**
|
|
16
64
|
* Hook attached by `ctx.form` (or `createForm`) so a Field can publish
|
|
@@ -34,12 +82,34 @@ export type FieldDevtoolsOwner = {
|
|
|
34
82
|
*/
|
|
35
83
|
export type ValidatorErrorReporter = (err: unknown) => void
|
|
36
84
|
|
|
85
|
+
/**
|
|
86
|
+
* When a field's validators are first allowed to run.
|
|
87
|
+
*
|
|
88
|
+
* - `'change'` (default) — validators run on every `set()`. Matches the
|
|
89
|
+
* current behavior, ideal for "type and see errors live."
|
|
90
|
+
* - `'blur'` — first run is gated on `markTouched()`. After that, subsequent
|
|
91
|
+
* value changes do trigger re-validation. UI binding should call
|
|
92
|
+
* `markTouched()` on `onBlur`.
|
|
93
|
+
* - `'submit'` — first run is gated on `revalidate()` / `Form.submit()`.
|
|
94
|
+
* After that, subsequent value changes re-validate. Use when you want
|
|
95
|
+
* "show errors only after the user explicitly tried to submit."
|
|
96
|
+
*
|
|
97
|
+
* `revalidate()` always unlocks the field regardless of mode.
|
|
98
|
+
*/
|
|
99
|
+
export type ValidateOn = 'change' | 'blur' | 'submit'
|
|
100
|
+
|
|
101
|
+
export type FieldOptions = {
|
|
102
|
+
onValidatorError?: ValidatorErrorReporter
|
|
103
|
+
validateOn?: ValidateOn
|
|
104
|
+
}
|
|
105
|
+
|
|
37
106
|
class FieldImpl<T> implements Field<T> {
|
|
38
107
|
private readonly value$: Signal<T>
|
|
39
108
|
/**
|
|
40
109
|
* Validator-produced errors. The public `errors` getter merges this with
|
|
41
|
-
* `serverErrors$` so consumers see a single flat array.
|
|
42
|
-
* re-run of validators (after a new value) doesn't clobber
|
|
110
|
+
* `serverErrors$` and `formErrors$` so consumers see a single flat array.
|
|
111
|
+
* Kept separate so a re-run of validators (after a new value) doesn't clobber
|
|
112
|
+
* the other two channels.
|
|
43
113
|
*/
|
|
44
114
|
private readonly validatorErrors$: Signal<string[]>
|
|
45
115
|
/**
|
|
@@ -47,11 +117,29 @@ class FieldImpl<T> implements Field<T> {
|
|
|
47
117
|
* `set()`, on `reset()`, or via an explicit `setErrors([])`.
|
|
48
118
|
*/
|
|
49
119
|
private readonly serverErrors$: Signal<string[]>
|
|
120
|
+
/**
|
|
121
|
+
* Errors routed here by a parent (or ancestor) form-level validator that
|
|
122
|
+
* targeted this field via a `FormIssue` path — the third error channel
|
|
123
|
+
* beside validator + server errors (T5.2). Owned by the routing form: cleared
|
|
124
|
+
* and re-applied on every form-level validation run, so it does NOT clear on
|
|
125
|
+
* the field's own `set()` (a stale cross-field error survives until the next
|
|
126
|
+
* form-level run recomputes it). Cleared by `reset()` / `setAsInitial()`.
|
|
127
|
+
*/
|
|
128
|
+
private readonly formErrors$: Signal<string[]>
|
|
50
129
|
private readonly errors$: Computed<string[]>
|
|
51
130
|
private readonly touched$: Signal<boolean>
|
|
52
131
|
private readonly dirty$: Signal<boolean>
|
|
53
132
|
private readonly validating$: Signal<boolean>
|
|
54
133
|
private readonly isValid$: Computed<boolean>
|
|
134
|
+
/**
|
|
135
|
+
* Validity as of the last *settled* validation pass. While a pass is in
|
|
136
|
+
* flight (`validating$`), `isValid` reads this instead of the live `errors`
|
|
137
|
+
* — otherwise a `debouncedValidator` would flip `isValid` to `false` on every
|
|
138
|
+
* keystroke (the async-start clears `validatorErrors$`), strobing a submit
|
|
139
|
+
* button. Updated only when a pass completes (T5.3). Defaults `true` (an
|
|
140
|
+
* untouched field with a pending first run reads valid, not invalid).
|
|
141
|
+
*/
|
|
142
|
+
private readonly lastValid$: Signal<boolean>
|
|
55
143
|
private readonly revalidateTrigger$: Signal<number>
|
|
56
144
|
|
|
57
145
|
private readonly validators: ReadonlyArray<Validator<T>>
|
|
@@ -64,12 +152,15 @@ class FieldImpl<T> implements Field<T> {
|
|
|
64
152
|
private disposed = false
|
|
65
153
|
private devtoolsOwner: FieldDevtoolsOwner | null = null
|
|
66
154
|
private onValidatorError: ValidatorErrorReporter | null = null
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
155
|
+
private readonly validateOn: ValidateOn
|
|
156
|
+
/** Reactive gate — when false, the validator effect skips its run. Flipped
|
|
157
|
+
* on by the relevant trigger for the field's `validateOn` mode. Once on,
|
|
158
|
+
* stays on for the lifetime of the field (matches RHF's `mode + reValidateMode`
|
|
159
|
+
* default semantics: after the first activation, subsequent changes
|
|
160
|
+
* re-validate). `reset()` flips it back to false. */
|
|
161
|
+
private readonly validateUnlocked$: Signal<boolean>
|
|
162
|
+
|
|
163
|
+
constructor(initial: T, validators: ReadonlyArray<Validator<T>> = [], options?: FieldOptions) {
|
|
73
164
|
this.initial = initial
|
|
74
165
|
this.validators = validators
|
|
75
166
|
// Capture the reporter BEFORE the validator effect kicks off so a sync
|
|
@@ -77,21 +168,33 @@ class FieldImpl<T> implements Field<T> {
|
|
|
77
168
|
// disappearing into the effect (`bindValidatorErrorReporter` is a
|
|
78
169
|
// post-construct hook so it can't catch the first run).
|
|
79
170
|
this.onValidatorError = options?.onValidatorError ?? null
|
|
171
|
+
this.validateOn = options?.validateOn ?? 'change'
|
|
80
172
|
this.value$ = signal(initial)
|
|
81
173
|
this.validatorErrors$ = signal<string[]>([])
|
|
82
174
|
this.serverErrors$ = signal<string[]>([])
|
|
175
|
+
this.formErrors$ = signal<string[]>([])
|
|
83
176
|
this.touched$ = signal(false)
|
|
84
177
|
this.dirty$ = signal(false)
|
|
85
178
|
this.validating$ = signal(false)
|
|
179
|
+
this.lastValid$ = signal(true)
|
|
86
180
|
this.revalidateTrigger$ = signal(0)
|
|
181
|
+
// 'change' mode is unlocked from construction; 'blur' / 'submit' wait
|
|
182
|
+
// for their trigger so initial typing doesn't surface errors.
|
|
183
|
+
this.validateUnlocked$ = signal(this.validateOn === 'change')
|
|
87
184
|
this.errors$ = computed(() => {
|
|
88
185
|
const v = this.validatorErrors$.value
|
|
89
186
|
const s = this.serverErrors$.value
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
|
|
187
|
+
const f = this.formErrors$.value
|
|
188
|
+
if (s.length === 0 && f.length === 0) return v
|
|
189
|
+
if (v.length === 0 && f.length === 0) return s
|
|
190
|
+
if (v.length === 0 && s.length === 0) return f
|
|
191
|
+
return [...v, ...s, ...f]
|
|
93
192
|
})
|
|
94
|
-
this.isValid$ = computed(() =>
|
|
193
|
+
this.isValid$ = computed(() =>
|
|
194
|
+
// While a validation pass is in flight, hold the last settled validity so
|
|
195
|
+
// the field doesn't strobe invalid mid-check (T5.3). Otherwise it's live.
|
|
196
|
+
this.validating$.value ? this.lastValid$.value : this.errors$.value.length === 0,
|
|
197
|
+
)
|
|
95
198
|
|
|
96
199
|
if (validators.length > 0) {
|
|
97
200
|
this.validatorDispose = effect(() => {
|
|
@@ -121,6 +224,10 @@ class FieldImpl<T> implements Field<T> {
|
|
|
121
224
|
return this.value$.subscribe(handler)
|
|
122
225
|
}
|
|
123
226
|
|
|
227
|
+
subscribeChanges(handler: (value: T) => void): () => void {
|
|
228
|
+
return this.value$.subscribeChanges(handler)
|
|
229
|
+
}
|
|
230
|
+
|
|
124
231
|
// --- Field-only signals ---
|
|
125
232
|
get errors(): ReadSignal<string[]> {
|
|
126
233
|
return this.errors$
|
|
@@ -147,7 +254,11 @@ class FieldImpl<T> implements Field<T> {
|
|
|
147
254
|
if (this.disposed) return
|
|
148
255
|
batch(() => {
|
|
149
256
|
this.value$.set(value)
|
|
150
|
-
|
|
257
|
+
// Equality-aware dirty: setting back to initial clears dirty, so
|
|
258
|
+
// "Disable Save when unchanged" UIs work without consumer code. Uses
|
|
259
|
+
// a structural comparison for primitive / shallow-object / array
|
|
260
|
+
// payloads; falls back to reference identity for class instances.
|
|
261
|
+
this.dirty$.set(!isStructurallyEqual(value, this.initial))
|
|
151
262
|
// Server errors are pinned externally and survive validator re-runs,
|
|
152
263
|
// but they MUST clear when the user edits the field — otherwise a
|
|
153
264
|
// server error like "username taken" would persist after the user
|
|
@@ -162,6 +273,19 @@ class FieldImpl<T> implements Field<T> {
|
|
|
162
273
|
this.serverErrors$.set(next)
|
|
163
274
|
}
|
|
164
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Internal — set the parent-form-validator error channel (`formErrors$`).
|
|
278
|
+
* Called by the owning form's issue router when a `FormIssue` path resolves
|
|
279
|
+
* to this field. Guards against churning the signal when nothing changes so
|
|
280
|
+
* a whole-tree clear pass doesn't wake unrelated subscribers. See T5.2 /
|
|
281
|
+
* `routeFormIssues` in `form.ts`.
|
|
282
|
+
*/
|
|
283
|
+
setFormErrors(errors: ReadonlyArray<string>): void {
|
|
284
|
+
if (this.disposed) return
|
|
285
|
+
if (this.formErrors$.peek().length === 0 && errors.length === 0) return
|
|
286
|
+
this.formErrors$.set(errors.length === 0 ? [] : [...errors])
|
|
287
|
+
}
|
|
288
|
+
|
|
165
289
|
/**
|
|
166
290
|
* Reseat the field as if this value had been its constructor `initial`.
|
|
167
291
|
* Sets the value, re-anchors `reset()`'s target, and does NOT mark dirty.
|
|
@@ -175,6 +299,13 @@ class FieldImpl<T> implements Field<T> {
|
|
|
175
299
|
batch(() => {
|
|
176
300
|
this.value$.set(value)
|
|
177
301
|
this.dirty$.set(false)
|
|
302
|
+
// Re-seating from a fresh server payload means the previous server
|
|
303
|
+
// response is no longer relevant. Without clearing, errors like
|
|
304
|
+
// "username taken" persist across a successful re-hydrate.
|
|
305
|
+
if (this.serverErrors$.peek().length > 0) this.serverErrors$.set([])
|
|
306
|
+
// A stale cross-field error from the old value is likewise irrelevant;
|
|
307
|
+
// the next form-level run recomputes it against the fresh value.
|
|
308
|
+
if (this.formErrors$.peek().length > 0) this.formErrors$.set([])
|
|
178
309
|
})
|
|
179
310
|
}
|
|
180
311
|
|
|
@@ -188,17 +319,30 @@ class FieldImpl<T> implements Field<T> {
|
|
|
188
319
|
this.touched$.set(false)
|
|
189
320
|
this.validatorErrors$.set([])
|
|
190
321
|
this.serverErrors$.set([])
|
|
322
|
+
this.formErrors$.set([])
|
|
191
323
|
this.validating$.set(false)
|
|
324
|
+
// Re-lock validation if the field was in blur/submit mode — a reset
|
|
325
|
+
// means we're back to a clean slate, so the user shouldn't immediately
|
|
326
|
+
// see errors again until they re-trigger.
|
|
327
|
+
if (this.validateOn !== 'change') this.validateUnlocked$.set(false)
|
|
192
328
|
})
|
|
193
329
|
}
|
|
194
330
|
|
|
195
331
|
markTouched(): void {
|
|
196
332
|
if (this.disposed) return
|
|
197
333
|
this.touched$.set(true)
|
|
334
|
+
// 'blur' mode unlocks validation on first blur. Subsequent set() calls
|
|
335
|
+
// then re-validate live (matches RHF `reValidateMode: onChange` default).
|
|
336
|
+
if (this.validateOn === 'blur' && !this.validateUnlocked$.peek()) {
|
|
337
|
+
this.validateUnlocked$.set(true)
|
|
338
|
+
}
|
|
198
339
|
}
|
|
199
340
|
|
|
200
341
|
async revalidate(): Promise<boolean> {
|
|
201
342
|
if (this.disposed) return this.isValid$.peek()
|
|
343
|
+
// `revalidate()` always unlocks the field — same trigger as a successful
|
|
344
|
+
// submit attempt. 'submit' mode uses this as its first activation.
|
|
345
|
+
if (!this.validateUnlocked$.peek()) this.validateUnlocked$.set(true)
|
|
202
346
|
// Bump the trigger to force re-run.
|
|
203
347
|
this.revalidateTrigger$.update((n) => n + 1)
|
|
204
348
|
await this.waitUntilSettled()
|
|
@@ -258,6 +402,17 @@ class FieldImpl<T> implements Field<T> {
|
|
|
258
402
|
// Track value and revalidate trigger.
|
|
259
403
|
const value = this.value$.value
|
|
260
404
|
void this.revalidateTrigger$.value
|
|
405
|
+
// Track the gate so the effect re-runs when the field becomes unlocked.
|
|
406
|
+
// While locked, skip the pass entirely — errors stay empty, the field
|
|
407
|
+
// reads as valid, no async work starts.
|
|
408
|
+
if (!this.validateUnlocked$.value) {
|
|
409
|
+
batch(() => {
|
|
410
|
+
if (this.validatorErrors$.peek().length > 0) this.validatorErrors$.set([])
|
|
411
|
+
if (this.validating$.peek()) this.validating$.set(false)
|
|
412
|
+
this.lastValid$.set(this.errors$.peek().length === 0)
|
|
413
|
+
})
|
|
414
|
+
return
|
|
415
|
+
}
|
|
261
416
|
|
|
262
417
|
// Abort previous in-flight run.
|
|
263
418
|
this.currentAbort?.abort()
|
|
@@ -266,7 +421,7 @@ class FieldImpl<T> implements Field<T> {
|
|
|
266
421
|
const myId = ++this.runId
|
|
267
422
|
|
|
268
423
|
const syncErrors: string[] = []
|
|
269
|
-
const asyncPromises: Promise<
|
|
424
|
+
const asyncPromises: Promise<ValidatorResult>[] = []
|
|
270
425
|
|
|
271
426
|
for (const validator of this.validators) {
|
|
272
427
|
try {
|
|
@@ -276,20 +431,28 @@ class FieldImpl<T> implements Field<T> {
|
|
|
276
431
|
// with a thrown error (rare but legal) — the catch-handler in
|
|
277
432
|
// `Promise.allSettled` covers true async rejection.
|
|
278
433
|
asyncPromises.push(result)
|
|
279
|
-
} else
|
|
280
|
-
|
|
434
|
+
} else {
|
|
435
|
+
// A Standard-Schema validator returns `FormIssue[]`; a stdlib one
|
|
436
|
+
// returns `string | null`. Flatten both to messages (a leaf field
|
|
437
|
+
// has no descendants to route issue paths to).
|
|
438
|
+
const msgs = messagesFromResult(result)
|
|
439
|
+
if (msgs.length > 0) syncErrors.push(...msgs)
|
|
281
440
|
}
|
|
282
441
|
} catch (err) {
|
|
283
442
|
// A buggy validator that throws synchronously: surface it twice.
|
|
284
|
-
// (1) Route through `onError` so the
|
|
285
|
-
// (2)
|
|
286
|
-
//
|
|
443
|
+
// (1) Route through `onError` so the developer knows something is wrong.
|
|
444
|
+
// (2) Mark the field invalid until the bug is fixed (don't pretend OK).
|
|
445
|
+
// In prod the user-visible message is generic — leaking an internal
|
|
446
|
+
// error's text into form errors is a footgun; the real error still
|
|
447
|
+
// reaches `onValidatorError` (T5.3). Dev keeps the message for DX.
|
|
287
448
|
try {
|
|
288
449
|
this.onValidatorError?.(err)
|
|
289
450
|
} catch {
|
|
290
451
|
// The reporter must not propagate.
|
|
291
452
|
}
|
|
292
|
-
syncErrors.push(
|
|
453
|
+
syncErrors.push(
|
|
454
|
+
__DEV__ ? (err instanceof Error ? err.message : String(err)) : 'Validation failed',
|
|
455
|
+
)
|
|
293
456
|
}
|
|
294
457
|
}
|
|
295
458
|
|
|
@@ -297,6 +460,7 @@ class FieldImpl<T> implements Field<T> {
|
|
|
297
460
|
batch(() => {
|
|
298
461
|
this.validatorErrors$.set(syncErrors)
|
|
299
462
|
this.validating$.set(false)
|
|
463
|
+
this.lastValid$.set(this.errors$.peek().length === 0)
|
|
300
464
|
})
|
|
301
465
|
this.emitValidated(false, syncErrors)
|
|
302
466
|
return
|
|
@@ -306,6 +470,7 @@ class FieldImpl<T> implements Field<T> {
|
|
|
306
470
|
batch(() => {
|
|
307
471
|
this.validatorErrors$.set([])
|
|
308
472
|
this.validating$.set(false)
|
|
473
|
+
this.lastValid$.set(this.errors$.peek().length === 0)
|
|
309
474
|
})
|
|
310
475
|
this.emitValidated(true, [])
|
|
311
476
|
return
|
|
@@ -321,7 +486,7 @@ class FieldImpl<T> implements Field<T> {
|
|
|
321
486
|
const asyncErrors: string[] = []
|
|
322
487
|
for (const r of results) {
|
|
323
488
|
if (r.status === 'fulfilled') {
|
|
324
|
-
|
|
489
|
+
asyncErrors.push(...messagesFromResult(r.value))
|
|
325
490
|
} else if (!isAbortError(r.reason)) {
|
|
326
491
|
const msg = r.reason instanceof Error ? r.reason.message : String(r.reason)
|
|
327
492
|
asyncErrors.push(msg)
|
|
@@ -330,6 +495,7 @@ class FieldImpl<T> implements Field<T> {
|
|
|
330
495
|
batch(() => {
|
|
331
496
|
this.validatorErrors$.set(asyncErrors)
|
|
332
497
|
this.validating$.set(false)
|
|
498
|
+
this.lastValid$.set(this.errors$.peek().length === 0)
|
|
333
499
|
})
|
|
334
500
|
this.emitValidated(asyncErrors.length === 0, asyncErrors)
|
|
335
501
|
})
|
|
@@ -367,20 +533,48 @@ export function bindFieldValidatorErrorReporter<T>(
|
|
|
367
533
|
export function createField<T>(
|
|
368
534
|
initial: T,
|
|
369
535
|
validators?: ReadonlyArray<Validator<T>>,
|
|
370
|
-
options?:
|
|
536
|
+
options?: FieldOptions,
|
|
371
537
|
): Field<T> {
|
|
372
538
|
return new FieldImpl(initial, validators, options)
|
|
373
539
|
}
|
|
374
540
|
|
|
541
|
+
/**
|
|
542
|
+
* A bidirectional `T ↔ string` transform, suitable for HTML input bindings
|
|
543
|
+
* where DOM values are always strings.
|
|
544
|
+
*
|
|
545
|
+
* `parse(raw)` converts the input's string value into the field's type;
|
|
546
|
+
* `format(value)` converts the field's typed value back into a string for
|
|
547
|
+
* the input. Both must be pure — `useFieldInput` calls them on every
|
|
548
|
+
* render and every input event respectively.
|
|
549
|
+
*
|
|
550
|
+
* ```ts
|
|
551
|
+
* const numberTransform: FieldTransform<number> = {
|
|
552
|
+
* parse: (raw) => Number(raw),
|
|
553
|
+
* format: (v) => String(v),
|
|
554
|
+
* }
|
|
555
|
+
* ```
|
|
556
|
+
*/
|
|
557
|
+
export type FieldTransform<T> = {
|
|
558
|
+
parse: (raw: string) => T
|
|
559
|
+
format: (value: T) => string
|
|
560
|
+
}
|
|
561
|
+
|
|
375
562
|
/**
|
|
376
563
|
* Wrap an async validator with a debounce. The debounce timer resets on every
|
|
377
564
|
* value change. While debouncing or the request is in flight, the field's
|
|
378
|
-
* `isValidating` is true and `isValid`
|
|
565
|
+
* `isValidating` is true and `isValid` HOLDS its last settled value (T5.3) — so
|
|
566
|
+
* editing an already-valid field doesn't strobe a submit button to disabled on
|
|
567
|
+
* every keystroke. A field with no prior settled validation defaults to valid.
|
|
379
568
|
*/
|
|
380
569
|
export function debouncedValidator<T>(
|
|
381
570
|
fn: (value: T, signal: AbortSignal) => Promise<string | null>,
|
|
382
571
|
ms: number,
|
|
383
|
-
):
|
|
572
|
+
): (value: T, signal: AbortSignal) => Promise<string | null> {
|
|
573
|
+
// Precise return (not the wide `Validator<T>`): the wrapped `fn` only ever
|
|
574
|
+
// yields `string | null`, so callers that invoke the debounced validator
|
|
575
|
+
// directly — e.g. surfacing its result in a signal — keep that narrow type
|
|
576
|
+
// after `Validator<T>` was widened to allow `FormIssue[]` (T5.2). Still
|
|
577
|
+
// assignable wherever a `Validator<T>` is expected.
|
|
384
578
|
return (value, signal) =>
|
|
385
579
|
new Promise<string | null>((resolve, reject) => {
|
|
386
580
|
if (signal.aborted) {
|
package/src/forms/form-types.ts
CHANGED
|
@@ -89,6 +89,12 @@ export type Form<S extends FormSchema> = {
|
|
|
89
89
|
readonly isDirty: ReadSignal<boolean>
|
|
90
90
|
readonly touched: ReadSignal<boolean>
|
|
91
91
|
readonly isValidating: ReadSignal<boolean>
|
|
92
|
+
/**
|
|
93
|
+
* Dotted paths of every leaf whose `isDirty` is true. Useful for PATCH
|
|
94
|
+
* payloads and "highlight changed inputs" UIs. Field paths use dot
|
|
95
|
+
* notation; array items use bracket notation (`items[0].title`).
|
|
96
|
+
*/
|
|
97
|
+
readonly dirtyFields: ReadSignal<string[]>
|
|
92
98
|
|
|
93
99
|
/**
|
|
94
100
|
* `true` while a `submit(...)` is in flight. Clears when the handler
|
|
@@ -117,6 +123,13 @@ export type Form<S extends FormSchema> = {
|
|
|
117
123
|
resetWithInitial(partial: DeepPartial<FormValue<S>>): void
|
|
118
124
|
/** Reset every leaf to its initial value. */
|
|
119
125
|
reset(): void
|
|
126
|
+
/**
|
|
127
|
+
* Reset a named subtree to its initial. Path uses the same dotted /
|
|
128
|
+
* bracket notation as `setErrors` / `flatErrors`. Unlike `Form.set({foo:
|
|
129
|
+
* undefined})` (which is "leave alone"), this is the explicit
|
|
130
|
+
* "clear this subtree" gesture. Pass `''` to reset the whole form.
|
|
131
|
+
*/
|
|
132
|
+
clearSubtree(path: string): void
|
|
120
133
|
/** Mark every leaf as touched (so error messages appear). */
|
|
121
134
|
markAllTouched(): void
|
|
122
135
|
/** Re-run every leaf's validators. Resolves with true if all leaves are valid. */
|
|
@@ -127,14 +140,14 @@ export type Form<S extends FormSchema> = {
|
|
|
127
140
|
* `submitError`. Returns `{ ok, data?, error? }` — see `FormImpl.submit`
|
|
128
141
|
* for the full contract.
|
|
129
142
|
*/
|
|
130
|
-
submit(
|
|
131
|
-
handler: (value: FormValue<S>) =>
|
|
143
|
+
submit<R = unknown>(
|
|
144
|
+
handler: (value: FormValue<S>) => R | Promise<R>,
|
|
132
145
|
options?: {
|
|
133
146
|
validateBeforeSubmit?: boolean
|
|
134
147
|
resetOnSuccess?: boolean
|
|
135
148
|
onError?: 'rethrow' | 'capture'
|
|
136
149
|
},
|
|
137
|
-
): Promise<{ ok: boolean; data?:
|
|
150
|
+
): Promise<{ ok: boolean; data?: Awaited<R>; error?: unknown }>
|
|
138
151
|
/**
|
|
139
152
|
* Pin externally-sourced errors on specific fields. Keys are dot-separated
|
|
140
153
|
* paths through nested forms / field arrays (numeric segments are array
|