mobx-formly 0.0.1 → 0.0.3

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.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Ref } from 'yummies/mobx';
2
- import type { FieldError, FieldPath, FieldPathValue, FieldState, FieldValues, FormOptions, RegisterOptions, RegisterReturn, ResetOptions, SetValueConfig, SubmitHandlers } from './types.js';
2
+ import type { FieldError, FieldErrors, FieldPath, FieldPathValue, FieldStateTree, FieldValues, FormOptions, RegisterOptions, RegisterReturn, ResetOptions, SetValueConfig, SubmitHandlers } from './types.js';
3
3
  export declare class Form<T extends FieldValues = FieldValues> {
4
4
  /**
5
5
  * Current form values.
@@ -8,11 +8,20 @@ export declare class Form<T extends FieldValues = FieldValues> {
8
8
  */
9
9
  values: T;
10
10
  /**
11
- * Errors keyed by field path.
11
+ * Validation errors nested by field path.
12
12
  *
13
13
  * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#errors)
14
14
  */
15
- errors: Record<string, FieldError | undefined>;
15
+ private readonly errorsByPath;
16
+ private readonly errorPathCounts;
17
+ private readonly errorChildren;
18
+ private readonly errorProxyCache;
19
+ private readonly fieldStatesByPath;
20
+ private readonly fieldStatePathCounts;
21
+ private readonly fieldStateChildren;
22
+ private readonly fieldStateProxyCache;
23
+ /** Validation errors nested by field path. */
24
+ get errors(): FieldErrors<T>;
16
25
  /**
17
26
  * Field paths whose values differ from their defaults.
18
27
  *
@@ -36,7 +45,8 @@ export declare class Form<T extends FieldValues = FieldValues> {
36
45
  *
37
46
  * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#fieldstate)
38
47
  */
39
- fieldState: Record<string, FieldState | undefined>;
48
+ /** Observable state for each registered field, nested by field path. */
49
+ get fieldState(): FieldStateTree<T>;
40
50
  /**
41
51
  * Whether a submission is currently running.
42
52
  *
@@ -190,4 +200,14 @@ export declare class Form<T extends FieldValues = FieldValues> {
190
200
  private ensureFieldState;
191
201
  private applyFieldState;
192
202
  private applyError;
203
+ private getError;
204
+ private hasError;
205
+ private errorPaths;
206
+ private deleteFieldState;
207
+ private fieldStates;
208
+ private setPathStore;
209
+ private deletePathStore;
210
+ private clearPathStore;
211
+ private addPathToIndex;
212
+ private createPathProxy;
193
213
  }
package/dist/form.js CHANGED
@@ -9,11 +9,22 @@ export class Form {
9
9
  */
10
10
  values;
11
11
  /**
12
- * Errors keyed by field path.
12
+ * Validation errors nested by field path.
13
13
  *
14
14
  * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#errors)
15
15
  */
16
- errors = {};
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();
24
+ /** Validation errors nested by field path. */
25
+ get errors() {
26
+ return this.createPathProxy('', this.errorsByPath, this.errorPathCounts, this.errorChildren, this.errorProxyCache);
27
+ }
17
28
  /**
18
29
  * Field paths whose values differ from their defaults.
19
30
  *
@@ -37,7 +48,10 @@ export class Form {
37
48
  *
38
49
  * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#fieldstate)
39
50
  */
40
- fieldState = {};
51
+ /** Observable state for each registered field, nested by field path. */
52
+ get fieldState() {
53
+ return this.createPathProxy('', this.fieldStatesByPath, this.fieldStatePathCounts, this.fieldStateChildren, this.fieldStateProxyCache);
54
+ }
41
55
  /**
42
56
  * Whether a submission is currently running.
43
57
  *
@@ -91,7 +105,6 @@ export class Form {
91
105
  this.values = clone((options.values ?? options.defaultValues ?? {}));
92
106
  makeObservable(this, {
93
107
  values: observable.deep,
94
- errors: observable.shallow,
95
108
  dirtyFields: observable.shallow,
96
109
  touchedFields: observable.shallow,
97
110
  validatingFields: observable.shallow,
@@ -100,7 +113,6 @@ export class Form {
100
113
  isSubmitSuccessful: observable,
101
114
  submitCount: observable,
102
115
  disabled: computed,
103
- fieldState: observable.deep,
104
116
  isDirty: computed,
105
117
  isValid: computed,
106
118
  snapshot: computed,
@@ -134,7 +146,7 @@ export class Form {
134
146
  *
135
147
  * [**Documentation**](https://js2me.github.io/mobx-formly/api/form.html#isvalid)
136
148
  */
137
- get isValid() { return Object.keys(this.errors).length === 0; }
149
+ get isValid() { return this.errorsByPath.size === 0; }
138
150
  /**
139
151
  * Registers a field and returns its ref and event handlers.
140
152
  *
@@ -165,7 +177,7 @@ export class Form {
165
177
  if (this.disabled)
166
178
  return;
167
179
  this.markTouched(path);
168
- if (this.options.mode === 'onBlur' || this.options.mode === 'all' || (path in this.errors && this.options.reValidateMode === 'onBlur'))
180
+ if (this.options.mode === 'onBlur' || this.options.mode === 'all' || (this.hasError(path) && this.options.reValidateMode === 'onBlur'))
169
181
  await this.trigger(path);
170
182
  },
171
183
  };
@@ -179,11 +191,11 @@ export class Form {
179
191
  const path = name;
180
192
  this.fieldValidationVersions.set(path, (this.fieldValidationVersions.get(path) ?? 0) + 1);
181
193
  deleteAtPath(this.values, path);
182
- delete this.errors[path];
194
+ this.applyError(path, undefined);
183
195
  delete this.dirtyFields[path];
184
196
  delete this.touchedFields[path];
185
197
  delete this.validatingFields[path];
186
- delete this.fieldState[path];
198
+ this.deleteFieldState(path);
187
199
  this.fieldOptions.delete(path);
188
200
  this.refs.delete(path);
189
201
  }
@@ -297,10 +309,9 @@ export class Form {
297
309
  */
298
310
  clearErrors(name) {
299
311
  if (!name) {
300
- this.errors = {};
301
- for (const state of Object.values(this.fieldState))
302
- if (state)
303
- this.applyFieldState(state, undefined);
312
+ this.clearPathStore(this.errorsByPath, this.errorPathCounts, this.errorChildren);
313
+ for (const [, state] of this.fieldStates())
314
+ this.applyFieldState(state, undefined);
304
315
  return;
305
316
  }
306
317
  for (const path of Array.isArray(name) ? name : [name])
@@ -325,11 +336,12 @@ export class Form {
325
336
  }
326
337
  try {
327
338
  const schemaErrors = await this.validateSchema();
328
- const validationPaths = paths ?? [...new Set([...this.fieldOptions.keys(), ...Object.keys(this.errors), ...Object.keys(schemaErrors)])];
339
+ const validationPaths = paths ?? [...new Set([...this.fieldOptions.keys(), ...this.errorsByPath.keys(), ...this.errorPaths(schemaErrors)])];
329
340
  runInAction(() => {
330
341
  for (const path of validationPaths) {
331
- if (schemaErrors[path] && this.isValidationCurrent(path, run, fieldVersions)) {
332
- this.applyError(path, schemaErrors[path]);
342
+ const schemaError = this.getError(schemaErrors, path);
343
+ if (schemaError && this.isValidationCurrent(path, run, fieldVersions)) {
344
+ this.applyError(path, schemaError);
333
345
  }
334
346
  }
335
347
  });
@@ -339,14 +351,14 @@ export class Form {
339
351
  if (this.isValidationCurrent(path, run, fieldVersions)) {
340
352
  if (ruleError)
341
353
  this.applyError(path, ruleError);
342
- else if (schemaErrors[path])
343
- this.applyError(path, schemaErrors[path]);
354
+ else if (this.getError(schemaErrors, path))
355
+ this.applyError(path, this.getError(schemaErrors, path));
344
356
  else
345
357
  this.applyError(path, undefined);
346
358
  }
347
359
  });
348
360
  }
349
- return (paths ?? Object.keys(this.errors)).every((path) => !this.errors[path]);
361
+ return (paths ?? [...this.errorsByPath.keys()]).every((path) => !this.hasError(path));
350
362
  }
351
363
  finally {
352
364
  runInAction(() => {
@@ -409,7 +421,7 @@ export class Form {
409
421
  this.validationVersion += 1;
410
422
  for (const path of Object.keys(this.validatingFields)) {
411
423
  delete this.validatingFields[path];
412
- const state = this.fieldState[path];
424
+ const state = this.fieldStatesByPath.get(path);
413
425
  if (state)
414
426
  state.isValidating = false;
415
427
  }
@@ -422,10 +434,9 @@ export class Form {
422
434
  if (!options.keepTouched)
423
435
  this.touchedFields = {};
424
436
  if (!options.keepErrors) {
425
- this.errors = {};
426
- for (const state of Object.values(this.fieldState))
427
- if (state)
428
- this.applyFieldState(state, undefined);
437
+ this.clearPathStore(this.errorsByPath, this.errorPathCounts, this.errorChildren);
438
+ for (const [, state] of this.fieldStates())
439
+ this.applyFieldState(state, undefined);
429
440
  }
430
441
  if (!options.keepIsSubmitted) {
431
442
  this.isSubmitted = false;
@@ -433,11 +444,10 @@ export class Form {
433
444
  }
434
445
  if (!options.keepSubmitCount)
435
446
  this.submitCount = 0;
436
- for (const [path, state] of Object.entries(this.fieldState))
437
- if (state) {
438
- state.isDirty = !!this.dirtyFields[path];
439
- state.isTouched = !!this.touchedFields[path];
440
- }
447
+ for (const [path, state] of this.fieldStates()) {
448
+ state.isDirty = !!this.dirtyFields[path];
449
+ state.isTouched = !!this.touchedFields[path];
450
+ }
441
451
  }
442
452
  /**
443
453
  * Resets one field to its current default value and clears its state.
@@ -448,7 +458,7 @@ export class Form {
448
458
  const path = name;
449
459
  this.fieldValidationVersions.set(path, (this.fieldValidationVersions.get(path) ?? 0) + 1);
450
460
  setAtPath(this.values, path, clone(getAtPath(this.defaultValues, path)));
451
- delete this.errors[path];
461
+ this.applyError(path, undefined);
452
462
  delete this.dirtyFields[path];
453
463
  delete this.touchedFields[path];
454
464
  delete this.validatingFields[path];
@@ -481,7 +491,7 @@ export class Form {
481
491
  this.dirtyFields[path] = true;
482
492
  this.ensureFieldState(path).isDirty = !!this.dirtyFields[path];
483
493
  }
484
- shouldValidateOnChange(path) { return this.options.mode === 'onChange' || this.options.mode === 'all' || (path in this.errors && this.options.reValidateMode === 'onChange'); }
494
+ shouldValidateOnChange(path) { return this.options.mode === 'onChange' || this.options.mode === 'all' || (this.hasError(path) && this.options.reValidateMode === 'onChange'); }
485
495
  isValidationCurrent(path, run, fieldVersions) {
486
496
  if (this.validationVersion !== run)
487
497
  return false;
@@ -516,8 +526,8 @@ export class Form {
516
526
  normalizeSchemaErrors(error) {
517
527
  return error.issues.reduce((errors, issue) => {
518
528
  const path = (issue.path ?? []).map((part) => String(typeof part === 'object' ? part.key : part)).join('.') || 'root';
519
- if (!errors[path])
520
- errors[path] = { type: issue.code ?? issue.type ?? 'validation', message: issue.message };
529
+ if (!this.getError(errors, path))
530
+ setAtPath(errors, path, { type: issue.code ?? issue.type ?? 'validation', message: issue.message });
521
531
  return errors;
522
532
  }, {});
523
533
  }
@@ -553,7 +563,12 @@ export class Form {
553
563
  return undefined;
554
564
  }
555
565
  ensureFieldState(path) {
556
- return (this.fieldState[path] ??= { invalid: false, isDirty: false, isTouched: false, isValidating: false });
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;
557
572
  }
558
573
  applyFieldState(state, error) {
559
574
  state.error = error;
@@ -561,9 +576,106 @@ export class Form {
561
576
  }
562
577
  applyError(path, error) {
563
578
  if (error)
564
- this.errors[path] = error;
579
+ this.setPathStore(this.errorsByPath, this.errorPathCounts, this.errorChildren, path, error);
565
580
  else
566
- delete this.errors[path];
581
+ this.deletePathStore(this.errorsByPath, this.errorPathCounts, this.errorChildren, path);
567
582
  this.applyFieldState(this.ensureFieldState(path), error);
568
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;
680
+ }
569
681
  }
package/dist/types.d.ts CHANGED
@@ -49,7 +49,11 @@ export interface FieldError {
49
49
  type: string;
50
50
  message?: string;
51
51
  }
52
- export type FieldErrors<T extends FieldValues = FieldValues> = Record<string, FieldError | undefined>;
52
+ export type FieldErrors<T extends object = FieldValues> = {
53
+ [K in keyof T]?: T[K] extends readonly (infer I)[] ? FieldError & Array<I extends object ? FieldErrors<I> : FieldError | undefined> : T[K] extends object ? FieldError & FieldErrors<T[K]> : FieldError;
54
+ } & {
55
+ root?: FieldError;
56
+ };
53
57
  export interface FieldState {
54
58
  invalid: boolean;
55
59
  isDirty: boolean;
@@ -57,7 +61,11 @@ export interface FieldState {
57
61
  isValidating: boolean;
58
62
  error?: FieldError;
59
63
  }
60
- export type FieldStateTree<T extends FieldValues = FieldValues> = Record<string, FieldState | undefined>;
64
+ export type FieldStateTree<T extends object = FieldValues> = {
65
+ [K in keyof T]?: T[K] extends readonly (infer I)[] ? FieldState & Array<I extends object ? FieldStateTree<I> : FieldState | undefined> : T[K] extends object ? FieldState & FieldStateTree<T[K]> : FieldState;
66
+ } & {
67
+ root?: FieldState;
68
+ };
61
69
  export interface FormState<T extends FieldValues = FieldValues> {
62
70
  errors: FieldErrors<T>;
63
71
  dirtyFields: Partial<Record<FieldPath<T>, true>>;
package/dist/utils.js CHANGED
@@ -41,8 +41,9 @@ export const deleteAtPath = (target, path) => {
41
41
  return undefined;
42
42
  return value[key];
43
43
  }, target);
44
- if (parent && typeof parent === 'object')
45
- delete parent[lastKey];
44
+ if (!parent || typeof parent !== 'object')
45
+ return;
46
+ delete parent[lastKey];
46
47
  };
47
48
  export const isEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b);
48
49
  export const extractValue = (eventOrValue) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobx-formly",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Framework-agnostic observable forms for MobX with granular field state and Zod or Valibot validation",
5
5
  "homepage": "https://js2me.github.io/mobx-formly/",
6
6
  "repository": {
@@ -19,17 +19,6 @@
19
19
  "import": "./dist/index.js"
20
20
  }
21
21
  },
22
- "scripts": {
23
- "build": "tsc -p tsconfig.json",
24
- "check": "tsc --noEmit -p tsconfig.json",
25
- "test": "vitest run",
26
- "test:performance": "vitest run tests/performance",
27
- "test:coverage": "vitest run --coverage",
28
- "test:types": "tsc --noEmit -p tsconfig.types.json",
29
- "docs:dev": "docusite dev",
30
- "docs:build": "docusite build",
31
- "docs:preview": "docusite preview"
32
- },
33
22
  "keywords": [
34
23
  "mobx",
35
24
  "mobx-form",
@@ -70,7 +59,6 @@
70
59
  "access": "public"
71
60
  },
72
61
  "type": "module",
73
- "packageManager": "pnpm@11.24.0",
74
62
  "dependencies": {
75
63
  "mobx": "^6.16.1",
76
64
  "yummies": "^7.20.1"
@@ -84,5 +72,16 @@
84
72
  "valibot": "^1.4.2",
85
73
  "vitest": "^4.1.11",
86
74
  "zod": "^4.4.3"
75
+ },
76
+ "scripts": {
77
+ "build": "tsc -p tsconfig.json",
78
+ "check": "tsc --noEmit -p tsconfig.json",
79
+ "test": "vitest run",
80
+ "test:performance": "vitest run tests/performance",
81
+ "test:coverage": "vitest run --coverage",
82
+ "test:types": "tsc --noEmit -p tsconfig.types.json",
83
+ "docs:dev": "docusite dev",
84
+ "docs:build": "docusite build",
85
+ "docs:preview": "docusite preview"
87
86
  }
88
- }
87
+ }
@@ -1,48 +0,0 @@
1
- import { type Ref } from 'yummies/mobx';
2
- import type { FieldError, FieldErrors, FieldPath, FieldState, FieldValues, FormOptions, FormState, RegisterOptions, RegisterReturn, ResetOptions, SetValueConfig, SubmitHandlers } from './types.js';
3
- export declare class Form<T extends FieldValues = FieldValues> {
4
- values: T;
5
- errors: FieldErrors<T>;
6
- dirtyFields: Partial<Record<FieldPath<T>, true>>;
7
- touchedFields: Partial<Record<FieldPath<T>, true>>;
8
- validatingFields: Partial<Record<FieldPath<T>, true>>;
9
- isSubmitting: boolean;
10
- isSubmitted: boolean;
11
- isSubmitSuccessful: boolean;
12
- submitCount: number;
13
- readonly refs: Map<string, Ref<HTMLElement | null, import("yummies/types").AnyObject>>;
14
- private readonly defaultValues;
15
- private readonly options;
16
- private readonly fieldOptions;
17
- private readonly listeners;
18
- constructor(options?: FormOptions<T>);
19
- get formState(): FormState<T>;
20
- get isDirty(): boolean;
21
- get isValid(): boolean;
22
- register(name: FieldPath<T>, options?: RegisterOptions<T>): RegisterReturn;
23
- unregister(name: FieldPath<T>): void;
24
- getValues(): T;
25
- getValues(name: FieldPath<T>): unknown;
26
- setValue(name: FieldPath<T>, value: unknown, config?: SetValueConfig): void;
27
- setError(name: FieldPath<T>, error: FieldError): void;
28
- clearErrors(name?: FieldPath<T> | FieldPath<T>[]): void;
29
- getFieldState(name: FieldPath<T>): FieldState;
30
- trigger(name?: FieldPath<T> | FieldPath<T>[]): Promise<boolean>;
31
- handleSubmit({ onValid, onInvalid }: SubmitHandlers<T>): () => Promise<void>;
32
- reset(values?: Partial<T>, options?: ResetOptions): void;
33
- resetField(name: FieldPath<T>): void;
34
- setFocus(name: FieldPath<T>): void;
35
- watch(): T;
36
- watch(name: FieldPath<T>): unknown;
37
- subscribe(listener: (values: T, info: {
38
- name?: string;
39
- }) => void): () => void;
40
- private notify;
41
- private markTouched;
42
- private updateDirty;
43
- private shouldValidateOnChange;
44
- private transformValue;
45
- private validateSchema;
46
- private zodErrors;
47
- private validateRules;
48
- }
package/dist/src/form.js DELETED
@@ -1,261 +0,0 @@
1
- import { makeAutoObservable, runInAction } from 'mobx';
2
- import { createRef } from 'yummies/mobx';
3
- import { clone, deleteAtPath, extractValue, getAtPath, isEqual, setAtPath } from './utils.js';
4
- export class Form {
5
- values;
6
- errors = {};
7
- dirtyFields = {};
8
- touchedFields = {};
9
- validatingFields = {};
10
- isSubmitting = false;
11
- isSubmitted = false;
12
- isSubmitSuccessful = false;
13
- submitCount = 0;
14
- refs = new Map();
15
- defaultValues;
16
- options;
17
- fieldOptions = new Map();
18
- listeners = new Set();
19
- constructor(options = {}) {
20
- this.options = {
21
- ...options,
22
- mode: options.mode ?? 'onSubmit',
23
- reValidateMode: options.reValidateMode ?? 'onChange',
24
- disabled: options.disabled ?? false,
25
- };
26
- this.defaultValues = clone((options.defaultValues ?? {}));
27
- this.values = clone((options.values ?? options.defaultValues ?? {}));
28
- makeAutoObservable(this, { defaultValues: false, options: false, fieldOptions: false, listeners: false, refs: false }, { autoBind: true });
29
- }
30
- get formState() {
31
- return {
32
- errors: this.errors,
33
- dirtyFields: this.dirtyFields,
34
- touchedFields: this.touchedFields,
35
- validatingFields: this.validatingFields,
36
- isDirty: Object.keys(this.dirtyFields).length > 0,
37
- isValid: Object.keys(this.errors).length === 0,
38
- isValidating: Object.keys(this.validatingFields).length > 0,
39
- isSubmitting: this.isSubmitting,
40
- isSubmitted: this.isSubmitted,
41
- isSubmitSuccessful: this.isSubmitSuccessful,
42
- submitCount: this.submitCount,
43
- disabled: this.options.disabled,
44
- };
45
- }
46
- get isDirty() { return this.formState.isDirty; }
47
- get isValid() { return this.formState.isValid; }
48
- register(name, options = {}) {
49
- const path = name;
50
- this.fieldOptions.set(path, options);
51
- let ref = this.refs.get(path);
52
- if (!ref) {
53
- ref = createRef();
54
- this.refs.set(path, ref);
55
- }
56
- return {
57
- name: path,
58
- ref,
59
- onChange: async (eventOrValue) => {
60
- const value = this.transformValue(extractValue(eventOrValue), options);
61
- const shouldValidate = this.shouldValidateOnChange(path);
62
- this.setValue(path, value, { shouldDirty: true, shouldValidate: false });
63
- if (shouldValidate)
64
- await this.trigger(path);
65
- },
66
- onBlur: async () => {
67
- this.markTouched(path);
68
- if (this.options.mode === 'onBlur' || this.options.mode === 'all' || (path in this.errors && this.options.reValidateMode === 'onBlur'))
69
- await this.trigger(path);
70
- },
71
- };
72
- }
73
- unregister(name) {
74
- const path = name;
75
- deleteAtPath(this.values, path);
76
- delete this.errors[path];
77
- delete this.dirtyFields[path];
78
- delete this.touchedFields[path];
79
- delete this.validatingFields[path];
80
- this.fieldOptions.delete(path);
81
- this.refs.delete(path);
82
- this.notify(path);
83
- }
84
- getValues(name) {
85
- return name === undefined ? clone(this.values) : getAtPath(this.values, name);
86
- }
87
- setValue(name, value, config = {}) {
88
- const path = name;
89
- setAtPath(this.values, path, value);
90
- if (config.shouldDirty ?? true)
91
- this.updateDirty(path);
92
- if (config.shouldTouch)
93
- this.markTouched(path);
94
- this.notify(path);
95
- if (config.shouldValidate)
96
- void this.trigger(path);
97
- }
98
- setError(name, error) {
99
- this.errors[name] = error;
100
- }
101
- clearErrors(name) {
102
- if (!name) {
103
- this.errors = {};
104
- return;
105
- }
106
- for (const path of Array.isArray(name) ? name : [name])
107
- delete this.errors[path];
108
- }
109
- getFieldState(name) {
110
- const path = name;
111
- return { invalid: !!this.errors[path], isDirty: !!this.dirtyFields[path], isTouched: !!this.touchedFields[path], error: this.errors[path] };
112
- }
113
- async trigger(name) {
114
- const paths = name ? (Array.isArray(name) ? name : [name]).map(String) : undefined;
115
- for (const path of paths ?? this.fieldOptions.keys())
116
- this.validatingFields[path] = true;
117
- try {
118
- const schemaErrors = await this.validateSchema();
119
- const validationPaths = paths ?? [...new Set([...this.fieldOptions.keys(), ...Object.keys(schemaErrors)])];
120
- runInAction(() => {
121
- for (const path of validationPaths) {
122
- const error = schemaErrors[path] ?? this.validateRules(path);
123
- if (error instanceof Promise)
124
- continue;
125
- if (error)
126
- this.errors[path] = error;
127
- else
128
- delete this.errors[path];
129
- }
130
- });
131
- for (const path of validationPaths) {
132
- const ruleError = await this.validateRules(path);
133
- runInAction(() => {
134
- if (ruleError)
135
- this.errors[path] = ruleError;
136
- else if (!schemaErrors[path])
137
- delete this.errors[path];
138
- });
139
- }
140
- return (paths ?? Object.keys(this.errors)).every((path) => !this.errors[path]);
141
- }
142
- finally {
143
- runInAction(() => {
144
- for (const path of paths ?? this.fieldOptions.keys())
145
- delete this.validatingFields[path];
146
- });
147
- }
148
- }
149
- handleSubmit({ onValid, onInvalid }) {
150
- return async () => {
151
- this.isSubmitting = true;
152
- this.isSubmitted = true;
153
- this.submitCount += 1;
154
- try {
155
- const valid = await this.trigger();
156
- if (valid) {
157
- await onValid(this.getValues(), this);
158
- runInAction(() => { this.isSubmitSuccessful = true; });
159
- }
160
- else {
161
- await onInvalid?.(this.errors, this);
162
- runInAction(() => { this.isSubmitSuccessful = false; });
163
- }
164
- }
165
- finally {
166
- runInAction(() => { this.isSubmitting = false; });
167
- }
168
- };
169
- }
170
- reset(values, options = {}) {
171
- const next = clone((values ?? this.defaultValues));
172
- this.values = next;
173
- if (!options.keepDefaultValues && values)
174
- Object.assign(this.defaultValues, clone(values));
175
- if (!options.keepDirty)
176
- this.dirtyFields = {};
177
- if (!options.keepTouched)
178
- this.touchedFields = {};
179
- if (!options.keepErrors)
180
- this.errors = {};
181
- if (!options.keepIsSubmitted) {
182
- this.isSubmitted = false;
183
- this.isSubmitSuccessful = false;
184
- }
185
- if (!options.keepSubmitCount)
186
- this.submitCount = 0;
187
- this.notify();
188
- }
189
- resetField(name) {
190
- const path = name;
191
- setAtPath(this.values, path, clone(getAtPath(this.defaultValues, path)));
192
- delete this.errors[path];
193
- delete this.dirtyFields[path];
194
- delete this.touchedFields[path];
195
- this.notify(path);
196
- }
197
- setFocus(name) { this.refs.get(name)?.current?.focus(); }
198
- watch(name) { return name === undefined ? this.values : getAtPath(this.values, name); }
199
- subscribe(listener) {
200
- this.listeners.add(listener);
201
- return () => this.listeners.delete(listener);
202
- }
203
- notify(name) { for (const listener of this.listeners)
204
- listener(this.getValues(), { name }); }
205
- markTouched(path) { this.touchedFields[path] = true; }
206
- updateDirty(path) { if (isEqual(getAtPath(this.values, path), getAtPath(this.defaultValues, path)))
207
- delete this.dirtyFields[path];
208
- else
209
- this.dirtyFields[path] = true; }
210
- shouldValidateOnChange(path) { return this.options.mode === 'onChange' || this.options.mode === 'all' || (path in this.errors && this.options.reValidateMode === 'onChange'); }
211
- transformValue(value, options) {
212
- if (options.setValueAs)
213
- return options.setValueAs(value);
214
- if (options.valueAsNumber)
215
- return value === '' ? Number.NaN : Number(value);
216
- if (options.valueAsDate)
217
- return new Date(String(value));
218
- return value;
219
- }
220
- async validateSchema() {
221
- if (!this.options.schema)
222
- return {};
223
- const result = await this.options.schema.safeParseAsync(this.getValues());
224
- if (result.success)
225
- return {};
226
- return this.zodErrors(result.error);
227
- }
228
- zodErrors(error) {
229
- return error.issues.reduce((errors, issue) => {
230
- const path = issue.path.map(String).join('.') || 'root';
231
- if (!errors[path])
232
- errors[path] = { type: issue.code, message: issue.message };
233
- return errors;
234
- }, {});
235
- }
236
- async validateRules(path) {
237
- const rules = this.fieldOptions.get(path);
238
- if (!rules)
239
- return undefined;
240
- const value = getAtPath(this.values, path);
241
- const fail = (type, rule) => typeof rule === 'string' ? { type, message: rule } : { type };
242
- if (rules.required && (value === undefined || value === null || value === ''))
243
- return fail('required', rules.required);
244
- if (rules.minLength && String(value ?? '').length < rules.minLength.value)
245
- return { type: 'minLength', message: rules.minLength.message };
246
- if (rules.maxLength && String(value ?? '').length > rules.maxLength.value)
247
- return { type: 'maxLength', message: rules.maxLength.message };
248
- if (rules.min && Number(value) < rules.min.value)
249
- return { type: 'min', message: rules.min.message };
250
- if (rules.max && Number(value) > rules.max.value)
251
- return { type: 'max', message: rules.max.message };
252
- if (rules.pattern && !rules.pattern.value.test(String(value ?? '')))
253
- return { type: 'pattern', message: rules.pattern.message };
254
- if (rules.validate) {
255
- const result = await rules.validate(value, this.getValues());
256
- if (result !== true)
257
- return { type: 'validate', message: typeof result === 'string' ? result : undefined };
258
- }
259
- return undefined;
260
- }
261
- }
@@ -1,2 +0,0 @@
1
- export { Form } from './form.js';
2
- export type { FieldError, FieldErrors, FieldPath, FieldState, FieldValues, FormOptions, FormState, RegisterOptions, RegisterReturn, ResetOptions, SetValueConfig, SubmitHandlers, } from './types.js';
package/dist/src/index.js DELETED
@@ -1 +0,0 @@
1
- export { Form } from './form.js';
@@ -1,88 +0,0 @@
1
- import type { Ref } from 'yummies/mobx';
2
- import type { ZodType } from 'zod';
3
- export type FieldValues = Record<string, unknown>;
4
- /** Dot-separated path into the form value tree, for example `user.email`. */
5
- export type FieldPath<T extends FieldValues = FieldValues> = string;
6
- export interface FieldError {
7
- type: string;
8
- message?: string;
9
- }
10
- export type FieldErrors<T extends FieldValues = FieldValues> = Partial<Record<FieldPath<T>, FieldError>>;
11
- export interface FieldState {
12
- invalid: boolean;
13
- isDirty: boolean;
14
- isTouched: boolean;
15
- error?: FieldError;
16
- }
17
- export interface FormState<T extends FieldValues = FieldValues> {
18
- errors: FieldErrors<T>;
19
- dirtyFields: Partial<Record<FieldPath<T>, true>>;
20
- touchedFields: Partial<Record<FieldPath<T>, true>>;
21
- validatingFields: Partial<Record<FieldPath<T>, true>>;
22
- isDirty: boolean;
23
- isValid: boolean;
24
- isValidating: boolean;
25
- isSubmitting: boolean;
26
- isSubmitted: boolean;
27
- isSubmitSuccessful: boolean;
28
- submitCount: number;
29
- disabled: boolean;
30
- }
31
- export interface RegisterOptions<T extends FieldValues = FieldValues> {
32
- required?: string | boolean;
33
- minLength?: {
34
- value: number;
35
- message?: string;
36
- };
37
- maxLength?: {
38
- value: number;
39
- message?: string;
40
- };
41
- min?: {
42
- value: number;
43
- message?: string;
44
- };
45
- max?: {
46
- value: number;
47
- message?: string;
48
- };
49
- pattern?: {
50
- value: RegExp;
51
- message?: string;
52
- };
53
- validate?: (value: unknown, values: T) => boolean | string | Promise<boolean | string>;
54
- valueAsNumber?: boolean;
55
- valueAsDate?: boolean;
56
- setValueAs?: (value: unknown) => unknown;
57
- }
58
- export interface RegisterReturn {
59
- name: string;
60
- ref: Ref<HTMLElement | null>;
61
- onChange: (eventOrValue: unknown) => Promise<void>;
62
- onBlur: () => Promise<void>;
63
- }
64
- export interface SetValueConfig {
65
- shouldValidate?: boolean;
66
- shouldDirty?: boolean;
67
- shouldTouch?: boolean;
68
- }
69
- export interface ResetOptions {
70
- keepDefaultValues?: boolean;
71
- keepDirty?: boolean;
72
- keepTouched?: boolean;
73
- keepErrors?: boolean;
74
- keepIsSubmitted?: boolean;
75
- keepSubmitCount?: boolean;
76
- }
77
- export interface FormOptions<T extends FieldValues> {
78
- defaultValues?: Partial<T>;
79
- values?: Partial<T>;
80
- schema?: ZodType<T>;
81
- mode?: 'onSubmit' | 'onChange' | 'onBlur' | 'all';
82
- reValidateMode?: 'onChange' | 'onBlur';
83
- disabled?: boolean;
84
- }
85
- export interface SubmitHandlers<T extends FieldValues> {
86
- onValid: (values: T, form: unknown) => void | Promise<void>;
87
- onInvalid?: (errors: FieldErrors<T>, form: unknown) => void | Promise<void>;
88
- }
package/dist/src/types.js DELETED
@@ -1 +0,0 @@
1
- export {};
@@ -1,6 +0,0 @@
1
- export declare const clone: <T>(value: T) => T;
2
- export declare const getAtPath: (source: unknown, path: string) => unknown;
3
- export declare const setAtPath: (target: Record<string, unknown>, path: string, value: unknown) => void;
4
- export declare const deleteAtPath: (target: Record<string, unknown>, path: string) => void;
5
- export declare const isEqual: (a: unknown, b: unknown) => boolean;
6
- export declare const extractValue: (eventOrValue: unknown) => unknown;
package/dist/src/utils.js DELETED
@@ -1,59 +0,0 @@
1
- import { isUnsafeProperty } from 'yummies/data';
2
- export const clone = (value) => {
3
- if (typeof structuredClone === 'function') {
4
- try {
5
- return structuredClone(value);
6
- }
7
- catch {
8
- // MobX observable proxies cannot be structured-cloned directly.
9
- }
10
- }
11
- return JSON.parse(JSON.stringify(value));
12
- };
13
- export const getAtPath = (source, path) => path.split('.').reduce((value, key) => {
14
- if (isUnsafeProperty(key) || value === null || value === undefined)
15
- return undefined;
16
- return value[key];
17
- }, source);
18
- export const setAtPath = (target, path, value) => {
19
- const keys = path.split('.');
20
- let current = target;
21
- for (const key of keys.slice(0, -1)) {
22
- if (isUnsafeProperty(key))
23
- return;
24
- const next = current[key];
25
- if (!next || typeof next !== 'object' || Array.isArray(next))
26
- current[key] = {};
27
- current = current[key];
28
- }
29
- const lastKey = keys.at(-1);
30
- if (lastKey && !isUnsafeProperty(lastKey))
31
- current[lastKey] = value;
32
- };
33
- export const deleteAtPath = (target, path) => {
34
- const keys = path.split('.');
35
- const lastKey = keys.pop();
36
- if (!lastKey || isUnsafeProperty(lastKey))
37
- return;
38
- const parent = keys.reduce((value, key) => {
39
- if (isUnsafeProperty(key) || !value || typeof value !== 'object')
40
- return undefined;
41
- return value[key];
42
- }, target);
43
- if (parent && typeof parent === 'object')
44
- delete parent[lastKey];
45
- };
46
- export const isEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b);
47
- export const extractValue = (eventOrValue) => {
48
- if (!eventOrValue || typeof eventOrValue !== 'object' || !('target' in eventOrValue))
49
- return eventOrValue;
50
- const target = eventOrValue.target;
51
- if (target.type === 'checkbox')
52
- return target.checked;
53
- if (target.type === 'radio')
54
- return target.checked ? target.value : undefined;
55
- if (target.multiple && target instanceof HTMLSelectElement) {
56
- return Array.from(target.selectedOptions, (option) => option.value);
57
- }
58
- return target.value;
59
- };