mobx-formly 0.0.3 → 0.1.1

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/form.js CHANGED
@@ -1,7 +1,11 @@
1
- import { action, computed, makeObservable, observable, observe, runInAction } from 'mobx';
1
+ import { action, computed, makeObservable, observable, runInAction } from 'mobx';
2
2
  import { createRef } from 'yummies/mobx';
3
3
  import { clone, deleteAtPath, extractValue, getAtPath, isEqual, setAtPath } from './utils.js';
4
- export class Form {
4
+ import { collectChangedPaths, collectDirtyPaths, collectErrorPaths, findErrorAtPath } from './utils.js';
5
+ import { PathStore } from './path-store.js';
6
+ import { FormValidator } from './validation.js';
7
+ const isPromiseLike = (value) => !!value && typeof value.then === 'function';
8
+ export class BaseForm {
5
9
  /**
6
10
  * Current form values.
7
11
  *
@@ -9,21 +13,17 @@ export class Form {
9
13
  */
10
14
  values;
11
15
  /**
12
- * Validation errors nested by field path.
16
+ * Cached default values used by reset, resetField, and dirty comparison.
17
+ * Updated by reset unless `keepDefaultValues` is passed.
13
18
  *
14
- * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#errors)
19
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#defaultvalues)
15
20
  */
16
- errorsByPath = observable.map();
17
- errorPathCounts = observable.map();
18
- errorChildren = new Map();
19
- errorProxyCache = new Map();
20
- fieldStatesByPath = observable.map();
21
- fieldStatePathCounts = observable.map();
22
- fieldStateChildren = new Map();
23
- fieldStateProxyCache = new Map();
21
+ defaultValues;
22
+ errorStore = new PathStore();
23
+ fieldStateStore = new PathStore();
24
24
  /** Validation errors nested by field path. */
25
25
  get errors() {
26
- return this.createPathProxy('', this.errorsByPath, this.errorPathCounts, this.errorChildren, this.errorProxyCache);
26
+ return this.errorStore.proxy();
27
27
  }
28
28
  /**
29
29
  * Field paths whose values differ from their defaults.
@@ -44,13 +44,12 @@ export class Form {
44
44
  */
45
45
  validatingFields = {};
46
46
  /**
47
- * Observable state for each registered field.
47
+ * Observable state for each registered field, nested by field path.
48
48
  *
49
49
  * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#fieldstate)
50
50
  */
51
- /** Observable state for each registered field, nested by field path. */
52
51
  get fieldState() {
53
- return this.createPathProxy('', this.fieldStatesByPath, this.fieldStatePathCounts, this.fieldStateChildren, this.fieldStateProxyCache);
52
+ return this.fieldStateStore.proxy();
54
53
  }
55
54
  /**
56
55
  * Whether a submission is currently running.
@@ -82,29 +81,38 @@ export class Form {
82
81
  * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#refs)
83
82
  */
84
83
  refs = new Map();
85
- defaultValues;
86
84
  options;
87
85
  fieldOptions = new Map();
88
- valueObservers;
89
- observerTimer;
90
- changedPaths = new Set();
91
- isMutating = false;
92
- observerTreeChanged = false;
86
+ touchedValidationFields = new Set();
87
+ validator;
93
88
  activeSubmissions = 0;
94
89
  resetVersion = 0;
90
+ isValidOverride = observable.box(undefined);
95
91
  validationVersion = 0;
96
92
  fieldValidationVersions = new Map();
93
+ /** Whether at least one validation pass has completed since construction or reset. */
94
+ hasValidationRun = false;
97
95
  constructor(options = {}) {
98
96
  this.options = {
99
97
  ...options,
100
98
  mode: options.mode ?? 'onSubmit',
101
99
  reValidateMode: options.reValidateMode ?? 'onChange',
102
100
  disabled: options.disabled ?? false,
101
+ shouldFocusError: options.shouldFocusError ?? true,
103
102
  };
104
103
  this.defaultValues = clone((options.defaultValues ?? {}));
105
104
  this.values = clone((options.values ?? options.defaultValues ?? {}));
105
+ this.validator = new FormValidator({
106
+ options: this.options,
107
+ fieldOptions: this.fieldOptions,
108
+ refs: this.refs,
109
+ valueAt: (path) => getAtPath(this.values, path),
110
+ snapshot: () => this.snapshot,
111
+ applyError: (path, error) => this.applyError(path, error),
112
+ });
106
113
  makeObservable(this, {
107
114
  values: observable.deep,
115
+ defaultValues: observable.deep,
108
116
  dirtyFields: observable.shallow,
109
117
  touchedFields: observable.shallow,
110
118
  validatingFields: observable.shallow,
@@ -114,6 +122,8 @@ export class Form {
114
122
  submitCount: observable,
115
123
  disabled: computed,
116
124
  isDirty: computed,
125
+ isTouched: computed,
126
+ isValidating: computed,
117
127
  isValid: computed,
118
128
  snapshot: computed,
119
129
  register: action,
@@ -142,11 +152,31 @@ export class Form {
142
152
  */
143
153
  get isDirty() { return Object.keys(this.dirtyFields).length > 0; }
144
154
  /**
145
- * Whether the form has no errors.
155
+ * Whether any field has been touched.
156
+ *
157
+ * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#istouched)
158
+ */
159
+ get isTouched() { return Object.keys(this.touchedFields).length > 0; }
160
+ /** Whether any field validation is currently running. */
161
+ get isValidating() { return Object.keys(this.validatingFields).length > 0; }
162
+ /**
163
+ * Whether the form has no errors. With a schema or resolver the first read
164
+ * schedules a full validation pass, so validity reflects the schema instead
165
+ * of defaulting to true until something validates.
146
166
  *
147
167
  * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#isvalid)
148
168
  */
149
- get isValid() { return this.errorsByPath.size === 0; }
169
+ get isValid() {
170
+ const override = this.isValidOverride.get();
171
+ if (override !== undefined)
172
+ return override;
173
+ if (!this.hasValidationRun && (this.options.schema || this.options.resolver)) {
174
+ this.hasValidationRun = true;
175
+ // Deferred so this computed getter stays free of observable side effects.
176
+ queueMicrotask(() => { void this.runValidation(); });
177
+ }
178
+ return this.errorStore.size === 0;
179
+ }
150
180
  /**
151
181
  * Registers a field and returns its ref and event handlers.
152
182
  *
@@ -155,11 +185,7 @@ export class Form {
155
185
  register(name, options = {}) {
156
186
  const path = name;
157
187
  this.fieldOptions.set(path, options);
158
- let ref = this.refs.get(path);
159
- if (!ref) {
160
- ref = createRef();
161
- this.refs.set(path, ref);
162
- }
188
+ const ref = this.ref(name);
163
189
  this.ensureFieldState(path);
164
190
  return {
165
191
  name: path,
@@ -169,19 +195,44 @@ export class Form {
169
195
  return;
170
196
  const value = this.transformValue(extractValue(eventOrValue), options);
171
197
  const shouldValidate = this.shouldValidateOnChange(path);
198
+ const deps = this.dependentFields(options.deps);
172
199
  this.setValue(path, value, { shouldDirty: true, shouldValidate: false });
173
- if (shouldValidate)
200
+ if (shouldValidate && deps.length) {
201
+ await Promise.all([this.trigger(path), this.trigger(deps)]);
202
+ }
203
+ else if (shouldValidate) {
174
204
  await this.trigger(path);
205
+ }
206
+ else if (deps.length) {
207
+ await this.trigger(deps);
208
+ }
175
209
  },
176
210
  onBlur: async () => {
177
211
  if (this.disabled)
178
212
  return;
179
213
  this.markTouched(path);
180
- if (this.options.mode === 'onBlur' || this.options.mode === 'all' || (this.hasError(path) && this.options.reValidateMode === 'onBlur'))
214
+ if (this.options.mode === 'onTouched')
215
+ this.touchedValidationFields.add(path);
216
+ if (this.options.mode === 'onBlur' || this.options.mode === 'onTouched' || this.options.mode === 'all' || (this.hasError(path) && this.options.reValidateMode === 'onBlur')) {
217
+ this.validator.flushDelayed(path);
181
218
  await this.trigger(path);
219
+ }
182
220
  },
183
221
  };
184
222
  }
223
+ /**
224
+ * Returns the stable MobX-aware ref for a field path, creating it on demand.
225
+ * The ref can be used by a view adapter before the field is registered.
226
+ */
227
+ ref(name) {
228
+ const path = name;
229
+ let ref = this.refs.get(path);
230
+ if (!ref) {
231
+ ref = createRef();
232
+ this.refs.set(path, ref);
233
+ }
234
+ return ref;
235
+ }
185
236
  /**
186
237
  * Removes a field, its value, and its associated state.
187
238
  *
@@ -190,12 +241,14 @@ export class Form {
190
241
  unregister(name) {
191
242
  const path = name;
192
243
  this.fieldValidationVersions.set(path, (this.fieldValidationVersions.get(path) ?? 0) + 1);
244
+ this.validator.cancelDelayed(path);
245
+ this.touchedValidationFields.delete(path);
193
246
  deleteAtPath(this.values, path);
194
247
  this.applyError(path, undefined);
195
248
  delete this.dirtyFields[path];
196
249
  delete this.touchedFields[path];
197
250
  delete this.validatingFields[path];
198
- this.deleteFieldState(path);
251
+ this.fieldStateStore.delete(path);
199
252
  this.fieldOptions.delete(path);
200
253
  this.refs.delete(path);
201
254
  }
@@ -209,98 +262,49 @@ export class Form {
209
262
  setAtPath(this.values, path, value);
210
263
  this.applyValueChange(path, config);
211
264
  }
212
- /**
213
- * Groups direct value changes and processes their changed paths together.
214
- *
215
- * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#mutatemutator-config)
216
- */
217
265
  mutate(mutator, config = {}) {
218
- this.ensureValueObservers();
219
- this.changedPaths.clear();
220
- this.isMutating = true;
221
- try {
222
- mutator();
223
- }
224
- finally {
225
- this.isMutating = false;
226
- }
227
- this.scheduleObserverCleanup();
228
- const paths = [...this.changedPaths];
229
- for (const path of paths)
230
- this.applyValueChange(path, { ...config, shouldValidate: false });
231
- if (paths.length && (config.shouldValidate ?? true)) {
232
- void this.trigger(paths);
233
- }
234
- if (this.observerTreeChanged) {
235
- this.disposeValueObservers();
236
- this.observerTreeChanged = false;
237
- this.ensureValueObservers();
266
+ const before = clone(this.values);
267
+ const result = mutator();
268
+ if (isPromiseLike(result)) {
269
+ return result.then(() => runInAction(() => this.finishMutation(before, config)), (error) => {
270
+ runInAction(() => this.finishMutation(before, config));
271
+ throw error;
272
+ });
238
273
  }
274
+ this.finishMutation(before, config);
275
+ }
276
+ finishMutation(before, config) {
277
+ if (isEqual(before, this.values))
278
+ return;
279
+ const changedPaths = collectChangedPaths(before, this.values);
280
+ const dirtyPaths = collectDirtyPaths(this.values, this.defaultValues);
281
+ if (config.shouldDirty ?? true)
282
+ this.syncDirtyFields(dirtyPaths);
283
+ if (config.shouldTouch ?? true)
284
+ for (const path of changedPaths)
285
+ this.markTouched(path);
286
+ if (config.shouldValidate ?? true)
287
+ void this.trigger();
239
288
  }
240
289
  applyValueChange(path, config) {
241
290
  if (config.shouldDirty ?? true)
242
291
  this.updateDirty(path);
243
- if (config.shouldTouch)
292
+ if (config.shouldTouch ?? true)
244
293
  this.markTouched(path);
245
294
  if (config.shouldValidate)
246
295
  void this.trigger(path);
247
296
  }
248
- ensureValueObservers() {
249
- if (!this.valueObservers || this.observerTreeChanged) {
250
- this.disposeValueObservers();
251
- this.valueObservers = [];
252
- this.observeValueTree(this.values, '', this.valueObservers);
253
- this.observerTreeChanged = false;
254
- }
255
- this.scheduleObserverCleanup();
256
- }
257
- scheduleObserverCleanup() {
258
- if (this.observerTimer)
259
- clearTimeout(this.observerTimer);
260
- this.observerTimer = setTimeout(() => this.disposeValueObservers(), 10 * 60 * 1000);
261
- const timer = this.observerTimer;
262
- timer.unref?.();
263
- }
264
- disposeValueObservers() {
265
- for (const dispose of this.valueObservers ?? [])
266
- dispose();
267
- this.valueObservers = undefined;
268
- this.observerTimer = undefined;
269
- }
270
- observeValueTree(value, basePath, disposers) {
271
- if (!value || typeof value !== 'object')
272
- return;
273
- if (Array.isArray(value)) {
274
- disposers.push(observe(value, (change) => {
275
- if (change.type === 'splice')
276
- this.observerTreeChanged = true;
277
- if (this.isMutating && basePath)
278
- this.changedPaths.add(basePath);
279
- }));
280
- }
281
- else {
282
- disposers.push(observe(value, (change) => {
283
- if (change.type === 'update' && (typeof change.newValue === 'object' || typeof change.oldValue === 'object')) {
284
- this.observerTreeChanged = true;
285
- }
286
- if (!this.isMutating)
287
- return;
288
- const path = basePath ? `${basePath}.${String(change.name)}` : String(change.name);
289
- if (path)
290
- this.changedPaths.add(path);
291
- }));
292
- }
293
- for (const [key, child] of Object.entries(value)) {
294
- this.observeValueTree(child, basePath ? `${basePath}.${key}` : key, disposers);
295
- }
296
- }
297
297
  /**
298
- * Sets an error for a field.
298
+ * Sets an error for a field and can focus it.
299
299
  *
300
300
  * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#seterrorname-error)
301
301
  */
302
- setError(name, error) {
303
- this.applyError(name, error);
302
+ setError(name, error, config = {}) {
303
+ const path = name;
304
+ this.validator.cancelDelayed(path);
305
+ this.applyError(path, error);
306
+ if (config.shouldFocus)
307
+ this.refs.get(path)?.current?.focus();
304
308
  }
305
309
  /**
306
310
  * Clears one, several, or all field errors.
@@ -309,20 +313,34 @@ export class Form {
309
313
  */
310
314
  clearErrors(name) {
311
315
  if (!name) {
312
- this.clearPathStore(this.errorsByPath, this.errorPathCounts, this.errorChildren);
313
- for (const [, state] of this.fieldStates())
316
+ this.validator.cancelAllDelayed();
317
+ this.isValidOverride.set(undefined);
318
+ this.errorStore.clear();
319
+ for (const [, state] of this.fieldStateStore.entries())
314
320
  this.applyFieldState(state, undefined);
315
321
  return;
316
322
  }
317
- for (const path of Array.isArray(name) ? name : [name])
318
- this.applyError(path, undefined);
323
+ for (const path of Array.isArray(name) ? name : [name]) {
324
+ this.clearErrorPath(path);
325
+ }
319
326
  }
320
327
  /**
321
328
  * Validates one field, several fields, or the complete form.
322
329
  *
323
330
  * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#triggername)
324
331
  */
325
- async trigger(name) {
332
+ async trigger(name, config = {}) {
333
+ const paths = name ? (Array.isArray(name) ? name : [name]).map(String) : [...this.fieldOptions.keys()];
334
+ if (config.shouldTouch)
335
+ for (const path of paths)
336
+ this.markTouched(path);
337
+ const valid = (await this.runValidation(name)).valid;
338
+ if (!valid && config.shouldFocus)
339
+ this.focusFirstError(paths);
340
+ return valid;
341
+ }
342
+ async runValidation(name) {
343
+ this.hasValidationRun = true;
326
344
  const paths = name ? (Array.isArray(name) ? name : [name]).map(String) : undefined;
327
345
  const run = ++this.validationVersion;
328
346
  const fieldVersions = new Map();
@@ -334,31 +352,24 @@ export class Form {
334
352
  this.validatingFields[path] = true;
335
353
  this.ensureFieldState(path).isValidating = true;
336
354
  }
355
+ let valid = true;
337
356
  try {
338
- const schemaErrors = await this.validateSchema();
339
- const validationPaths = paths ?? [...new Set([...this.fieldOptions.keys(), ...this.errorsByPath.keys(), ...this.errorPaths(schemaErrors)])];
340
- runInAction(() => {
341
- for (const path of validationPaths) {
342
- const schemaError = this.getError(schemaErrors, path);
343
- if (schemaError && this.isValidationCurrent(path, run, fieldVersions)) {
344
- this.applyError(path, schemaError);
345
- }
346
- }
347
- });
357
+ const schemaResult = await this.validator.resolveSchemaErrors(paths);
358
+ const schemaErrors = schemaResult.errors;
359
+ const validationPaths = paths ?? [...new Set([...this.fieldOptions.keys(), ...this.errorStore.paths(), ...collectErrorPaths(schemaErrors)])];
348
360
  for (const path of validationPaths) {
349
- const ruleError = await this.validateRules(path);
361
+ const ruleError = await this.validator.validateRules(path);
350
362
  runInAction(() => {
351
363
  if (this.isValidationCurrent(path, run, fieldVersions)) {
352
- if (ruleError)
353
- this.applyError(path, ruleError);
354
- else if (this.getError(schemaErrors, path))
355
- this.applyError(path, this.getError(schemaErrors, path));
356
- else
357
- this.applyError(path, undefined);
364
+ const schemaError = findErrorAtPath(schemaErrors, path);
365
+ const error = this.validator.mergeErrors(schemaError, ruleError);
366
+ if (error)
367
+ valid = false;
368
+ this.validator.applyValidationError(path, error);
358
369
  }
359
370
  });
360
371
  }
361
- return (paths ?? [...this.errorsByPath.keys()]).every((path) => !this.hasError(path));
372
+ return { valid, values: valid ? schemaResult.values : undefined };
362
373
  }
363
374
  finally {
364
375
  runInAction(() => {
@@ -386,15 +397,17 @@ export class Form {
386
397
  this.submitCount += 1;
387
398
  const submissionResetVersion = this.resetVersion;
388
399
  try {
389
- const valid = await this.trigger();
400
+ const { valid, values } = await this.runValidation();
390
401
  if (valid) {
391
- await onValid(this.snapshot, this);
402
+ await onValid(values ?? this.snapshot, this);
392
403
  runInAction(() => {
393
404
  if (this.resetVersion === submissionResetVersion)
394
405
  this.isSubmitSuccessful = true;
395
406
  });
396
407
  }
397
408
  else {
409
+ if (this.options.shouldFocusError)
410
+ this.focusFirstError();
398
411
  await onInvalid?.(this.errors, this);
399
412
  runInAction(() => {
400
413
  if (this.resetVersion === submissionResetVersion)
@@ -416,35 +429,52 @@ export class Form {
416
429
  * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#resetvalues-options)
417
430
  */
418
431
  reset(values, options = {}) {
419
- this.disposeValueObservers();
432
+ this.validator.cancelAllDelayed();
433
+ this.touchedValidationFields.clear();
420
434
  this.resetVersion += 1;
421
435
  this.validationVersion += 1;
422
- for (const path of Object.keys(this.validatingFields)) {
423
- delete this.validatingFields[path];
424
- const state = this.fieldStatesByPath.get(path);
425
- if (state)
426
- state.isValidating = false;
436
+ if (!options.keepIsValidating) {
437
+ for (const path of Object.keys(this.validatingFields)) {
438
+ delete this.validatingFields[path];
439
+ const state = this.fieldStateStore.get(path);
440
+ if (state)
441
+ state.isValidating = false;
442
+ }
443
+ }
444
+ if (!options.keepValues) {
445
+ const next = clone((values ?? this.defaultValues));
446
+ if (options.keepDirtyValues) {
447
+ for (const path of Object.keys(this.dirtyFields)) {
448
+ const value = getAtPath(this.values, path);
449
+ if (value !== undefined)
450
+ setAtPath(next, path, clone(value));
451
+ }
452
+ }
453
+ this.values = next;
454
+ // New values need a fresh validity check on the next isValid read.
455
+ this.hasValidationRun = false;
427
456
  }
428
- const next = clone((values ?? this.defaultValues));
429
- this.values = next;
430
457
  if (!options.keepDefaultValues && values)
431
458
  Object.assign(this.defaultValues, clone(values));
432
- if (!options.keepDirty)
459
+ if (!options.keepDirty && !options.keepDirtyValues)
433
460
  this.dirtyFields = {};
434
461
  if (!options.keepTouched)
435
462
  this.touchedFields = {};
463
+ // Raw validity without the lazy warm-up: reset must never schedule validation.
464
+ const wasValid = this.isValidOverride.get() ?? this.errorStore.size === 0;
436
465
  if (!options.keepErrors) {
437
- this.clearPathStore(this.errorsByPath, this.errorPathCounts, this.errorChildren);
438
- for (const [, state] of this.fieldStates())
466
+ this.errorStore.clear();
467
+ for (const [, state] of this.fieldStateStore.entries())
439
468
  this.applyFieldState(state, undefined);
440
469
  }
441
- if (!options.keepIsSubmitted) {
470
+ this.isValidOverride.set(options.keepIsValid ? wasValid : undefined);
471
+ if (!options.keepIsSubmitted)
442
472
  this.isSubmitted = false;
473
+ if (!options.keepIsSubmitSuccessful)
443
474
  this.isSubmitSuccessful = false;
444
- }
445
475
  if (!options.keepSubmitCount)
446
476
  this.submitCount = 0;
447
- for (const [path, state] of this.fieldStates()) {
477
+ for (const [path, state] of this.fieldStateStore.entries()) {
448
478
  state.isDirty = !!this.dirtyFields[path];
449
479
  state.isTouched = !!this.touchedFields[path];
450
480
  }
@@ -454,18 +484,26 @@ export class Form {
454
484
  *
455
485
  * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#resetfieldname)
456
486
  */
457
- resetField(name) {
487
+ resetField(name, options = {}) {
458
488
  const path = name;
489
+ this.validator.cancelDelayed(path);
490
+ this.touchedValidationFields.delete(path);
459
491
  this.fieldValidationVersions.set(path, (this.fieldValidationVersions.get(path) ?? 0) + 1);
460
- setAtPath(this.values, path, clone(getAtPath(this.defaultValues, path)));
461
- this.applyError(path, undefined);
462
- delete this.dirtyFields[path];
463
- delete this.touchedFields[path];
492
+ if ('defaultValue' in options)
493
+ setAtPath(this.defaultValues, path, clone(options.defaultValue));
494
+ setAtPath(this.values, path, clone('defaultValue' in options ? options.defaultValue : getAtPath(this.defaultValues, path)));
495
+ if (!options.keepError)
496
+ this.applyError(path, undefined);
497
+ if (!options.keepDirty)
498
+ delete this.dirtyFields[path];
499
+ if (!options.keepTouched)
500
+ delete this.touchedFields[path];
464
501
  delete this.validatingFields[path];
465
502
  const state = this.ensureFieldState(path);
466
- this.applyFieldState(state, undefined);
467
- state.isDirty = false;
468
- state.isTouched = false;
503
+ if (!options.keepError)
504
+ this.applyFieldState(state, undefined);
505
+ state.isDirty = options.keepDirty ? !!this.dirtyFields[path] : false;
506
+ state.isTouched = options.keepTouched ? !!this.touchedFields[path] : false;
469
507
  state.isValidating = false;
470
508
  }
471
509
  /**
@@ -480,6 +518,46 @@ export class Form {
480
518
  * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#snapshot)
481
519
  */
482
520
  get snapshot() { return clone(this.values); }
521
+ /** Reconciles dirty paths against the complete current value tree. */
522
+ syncDirtyFields(paths) {
523
+ const dirty = new Set(paths);
524
+ this.dirtyFields = Object.fromEntries(paths.map((path) => [path, true]));
525
+ for (const [path, state] of this.fieldStateStore.entries())
526
+ state.isDirty = dirty.has(path);
527
+ for (const path of paths)
528
+ this.ensureFieldState(path).isDirty = true;
529
+ }
530
+ /** Clears an exact error path and every nested error below it. */
531
+ clearErrorPath(path) {
532
+ const paths = this.errorStore.paths().filter((stored) => stored === path || stored.startsWith(`${path}.`));
533
+ if (!paths.length) {
534
+ this.validator.cancelDelayed(path);
535
+ this.applyError(path, undefined);
536
+ return;
537
+ }
538
+ for (const storedPath of paths) {
539
+ this.validator.cancelDelayed(storedPath);
540
+ this.applyError(storedPath, undefined);
541
+ }
542
+ }
543
+ /** Focuses the first errored registered field, skipping form-level errors. */
544
+ focusFirstError(paths) {
545
+ const candidates = paths ?? this.errorStore.paths();
546
+ for (const path of candidates) {
547
+ if (!this.hasError(path))
548
+ continue;
549
+ const ref = this.refs.get(path)?.current;
550
+ if (ref?.focus) {
551
+ ref.focus();
552
+ return;
553
+ }
554
+ }
555
+ }
556
+ dependentFields(deps) {
557
+ if (!deps)
558
+ return [];
559
+ return (Array.isArray(deps) ? deps : [deps]).map(String);
560
+ }
483
561
  markTouched(path) {
484
562
  this.touchedFields[path] = true;
485
563
  this.ensureFieldState(path).isTouched = true;
@@ -491,7 +569,12 @@ export class Form {
491
569
  this.dirtyFields[path] = true;
492
570
  this.ensureFieldState(path).isDirty = !!this.dirtyFields[path];
493
571
  }
494
- shouldValidateOnChange(path) { return this.options.mode === 'onChange' || this.options.mode === 'all' || (this.hasError(path) && this.options.reValidateMode === 'onChange'); }
572
+ shouldValidateOnChange(path) {
573
+ return this.options.mode === 'onChange'
574
+ || this.options.mode === 'all'
575
+ || (this.options.mode === 'onTouched' && this.touchedValidationFields.has(path))
576
+ || (this.hasError(path) && this.options.reValidateMode === 'onChange');
577
+ }
495
578
  isValidationCurrent(path, run, fieldVersions) {
496
579
  if (this.validationVersion !== run)
497
580
  return false;
@@ -508,174 +591,22 @@ export class Form {
508
591
  return new Date(String(value));
509
592
  return value;
510
593
  }
511
- async validateSchema() {
512
- if (!this.options.schema)
513
- return {};
514
- const schema = this.options.schema;
515
- if ('safeParseAsync' in schema) {
516
- const result = await schema.safeParseAsync(this.snapshot);
517
- if (result.success)
518
- return {};
519
- return this.normalizeSchemaErrors(result.error);
520
- }
521
- const result = await schema['~run']({ value: this.snapshot, typed: false }, {});
522
- if (!result.issues?.length)
523
- return {};
524
- return this.normalizeSchemaErrors({ issues: result.issues });
525
- }
526
- normalizeSchemaErrors(error) {
527
- return error.issues.reduce((errors, issue) => {
528
- const path = (issue.path ?? []).map((part) => String(typeof part === 'object' ? part.key : part)).join('.') || 'root';
529
- if (!this.getError(errors, path))
530
- setAtPath(errors, path, { type: issue.code ?? issue.type ?? 'validation', message: issue.message });
531
- return errors;
532
- }, {});
533
- }
534
- async validateRules(path) {
535
- const rules = this.fieldOptions.get(path);
536
- if (!rules)
537
- return undefined;
538
- const value = getAtPath(this.values, path);
539
- const fail = (type, rule) => typeof rule === 'string' ? { type, message: rule } : { type };
540
- if (rules.required && (value === undefined || value === null || value === ''))
541
- return fail('required', rules.required);
542
- if (rules.minLength && String(value ?? '').length < rules.minLength.value)
543
- return { type: 'minLength', message: rules.minLength.message };
544
- if (rules.maxLength && String(value ?? '').length > rules.maxLength.value)
545
- return { type: 'maxLength', message: rules.maxLength.message };
546
- if (rules.min && Number(value) < rules.min.value)
547
- return { type: 'min', message: rules.min.message };
548
- if (rules.max && Number(value) > rules.max.value)
549
- return { type: 'max', message: rules.max.message };
550
- if (rules.pattern && !rules.pattern.value.test(String(value ?? '')))
551
- return { type: 'pattern', message: rules.pattern.message };
552
- if (rules.validate) {
553
- let result;
554
- try {
555
- result = await rules.validate(value, this.snapshot);
556
- }
557
- catch {
558
- return { type: 'validate', message: 'Validation failed' };
559
- }
560
- if (result !== true)
561
- return { type: 'validate', message: typeof result === 'string' ? result : undefined };
562
- }
563
- return undefined;
564
- }
565
594
  ensureFieldState(path) {
566
- const existing = this.fieldStatesByPath.get(path);
567
- if (existing)
568
- return existing;
569
- const state = { invalid: false, isDirty: false, isTouched: false, isValidating: false };
570
- this.setPathStore(this.fieldStatesByPath, this.fieldStatePathCounts, this.fieldStateChildren, path, state);
571
- return state;
595
+ return this.fieldStateStore.ensure(path, () => ({ invalid: false, isDirty: false, isTouched: false, isValidating: false, error: undefined }));
572
596
  }
573
597
  applyFieldState(state, error) {
574
598
  state.error = error;
575
599
  state.invalid = !!error;
576
600
  }
577
601
  applyError(path, error) {
602
+ this.isValidOverride.set(undefined);
578
603
  if (error)
579
- this.setPathStore(this.errorsByPath, this.errorPathCounts, this.errorChildren, path, error);
604
+ this.errorStore.set(path, error);
580
605
  else
581
- this.deletePathStore(this.errorsByPath, this.errorPathCounts, this.errorChildren, path);
582
- this.applyFieldState(this.ensureFieldState(path), error);
583
- }
584
- getError(errors, path) {
585
- const value = getAtPath(errors, path);
586
- return value && typeof value === 'object' && 'type' in value ? value : undefined;
587
- }
588
- hasError(path) { return this.errorsByPath.has(path); }
589
- errorPaths(errors, base = '') {
590
- const paths = [];
591
- for (const [key, value] of Object.entries(errors)) {
592
- if (value === undefined)
593
- continue;
594
- const path = base ? `${base}.${key}` : key;
595
- if (value && typeof value === 'object' && 'type' in value)
596
- paths.push(path);
597
- if (value && typeof value === 'object')
598
- paths.push(...this.errorPaths(value, path));
599
- }
600
- return paths;
601
- }
602
- deleteFieldState(path) {
603
- this.deletePathStore(this.fieldStatesByPath, this.fieldStatePathCounts, this.fieldStateChildren, path);
604
- }
605
- fieldStates() {
606
- return [...this.fieldStatesByPath.entries()];
607
- }
608
- setPathStore(store, counts, children, path, value) {
609
- if (!store.has(path))
610
- this.addPathToIndex(counts, children, path);
611
- store.set(path, value);
612
- }
613
- deletePathStore(store, counts, children, path) {
614
- if (!store.delete(path))
615
- return;
616
- const parts = path.split('.');
617
- for (let index = parts.length; index > 0; index -= 1) {
618
- const current = parts.slice(0, index).join('.');
619
- const count = (counts.get(current) ?? 1) - 1;
620
- if (count > 0) {
621
- counts.set(current, count);
622
- continue;
623
- }
624
- counts.delete(current);
625
- const parent = parts.slice(0, index - 1).join('.');
626
- const siblings = children.get(parent);
627
- siblings?.delete(parts[index - 1]);
628
- if (siblings?.size === 0)
629
- children.delete(parent);
630
- }
631
- }
632
- clearPathStore(store, counts, children) {
633
- store.clear();
634
- counts.clear();
635
- children.clear();
636
- }
637
- addPathToIndex(counts, children, path) {
638
- const parts = path.split('.');
639
- for (let index = 1; index <= parts.length; index += 1) {
640
- const current = parts.slice(0, index).join('.');
641
- counts.set(current, (counts.get(current) ?? 0) + 1);
642
- const parent = parts.slice(0, index - 1).join('.');
643
- let siblings = children.get(parent);
644
- if (!siblings)
645
- children.set(parent, siblings = new Set());
646
- siblings.add(parts[index - 1]);
647
- }
648
- }
649
- createPathProxy(path, store, counts, children, cache) {
650
- const cached = cache.get(path);
651
- if (cached)
652
- return cached;
653
- const proxy = new Proxy({}, {
654
- get: (_, property) => {
655
- if (typeof property !== 'string')
656
- return undefined;
657
- const current = store.get(path);
658
- if (current && property in current)
659
- return current[property];
660
- const childPath = path ? `${path}.${property}` : property;
661
- const value = store.get(childPath);
662
- if (value && !children.has(childPath))
663
- return value;
664
- return counts.has(childPath) ? this.createPathProxy(childPath, store, counts, children, cache) : undefined;
665
- },
666
- ownKeys: () => [...Object.keys(store.get(path) ?? {}), ...(children.get(path) ?? [])],
667
- getOwnPropertyDescriptor: (_, property) => {
668
- if (typeof property !== 'string')
669
- return undefined;
670
- const value = store.get(path);
671
- if (value && property in value)
672
- return { configurable: true, enumerable: true, value: value[property] };
673
- return children.get(path)?.has(property) ? { configurable: true, enumerable: true } : undefined;
674
- },
675
- set: () => false,
676
- deleteProperty: () => false,
677
- });
678
- cache.set(path, proxy);
679
- return proxy;
606
+ this.errorStore.delete(path);
607
+ const state = this.ensureFieldState(path);
608
+ state.error = error;
609
+ state.invalid = !!error;
680
610
  }
611
+ hasError(path) { return this.errorStore.has(path); }
681
612
  }