@goodie-forms/core 1.1.5-alpha → 1.2.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +426 -426
  2. package/dist/field/FieldPath.d.ts +32 -0
  3. package/dist/field/FieldPath.d.ts.map +1 -0
  4. package/dist/field/FieldPathBuilder.d.ts +25 -0
  5. package/dist/field/FieldPathBuilder.d.ts.map +1 -0
  6. package/dist/field/Reconcile.d.ts +9 -0
  7. package/dist/field/Reconcile.d.ts.map +1 -0
  8. package/dist/form/FormController.d.ts +35 -39
  9. package/dist/form/FormController.d.ts.map +1 -1
  10. package/dist/form/FormField.d.ts +12 -10
  11. package/dist/form/FormField.d.ts.map +1 -1
  12. package/dist/form/NonullFormField.d.ts +3 -4
  13. package/dist/form/NonullFormField.d.ts.map +1 -1
  14. package/dist/index.d.ts +7 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +615 -881
  17. package/dist/index.js.map +1 -1
  18. package/dist/validation/CustomValidation.d.ts +10 -0
  19. package/dist/validation/CustomValidation.d.ts.map +1 -0
  20. package/package.json +1 -1
  21. package/src/field/FieldPath.ts +292 -0
  22. package/src/field/FieldPathBuilder.ts +132 -0
  23. package/src/field/Reconcile.ts +99 -0
  24. package/src/form/FormController.ts +342 -310
  25. package/src/form/FormField.ts +200 -202
  26. package/src/form/NonullFormField.ts +15 -21
  27. package/src/index.ts +12 -5
  28. package/src/types/DeepPartial.ts +7 -7
  29. package/src/types/Mixin.ts +2 -2
  30. package/src/utils/ensureImmerability.ts +30 -30
  31. package/src/utils/getId.ts +5 -5
  32. package/src/utils/removeBy.ts +11 -11
  33. package/src/{form → validation}/CustomValidation.ts +52 -52
  34. package/tsconfig.json +8 -7
  35. package/vite.config.ts +18 -18
  36. package/dist/form/CustomValidation.d.ts +0 -10
  37. package/dist/form/CustomValidation.d.ts.map +0 -1
  38. package/dist/form/Field.d.ts +0 -31
  39. package/dist/form/Field.d.ts.map +0 -1
  40. package/src/form/Field.ts +0 -334
@@ -1,310 +1,342 @@
1
- import { StandardSchemaV1 } from "@standard-schema/spec";
2
- import { enableArrayMethods, enableMapSet, produce } from "immer";
3
- import { createNanoEvents } from "nanoevents";
4
- import { Field } from "../form/Field";
5
- import { DeepPartial } from "../types/DeepPartial";
6
- import { removeBy } from "../utils/removeBy";
7
- import { FormField } from "./FormField";
8
- import { ensureImmerability } from "../utils/ensureImmerability";
9
-
10
- enableMapSet();
11
- enableArrayMethods();
12
-
13
- export namespace Form {
14
- export type FormConfigs<TShape extends object> = ConstructorParameters<
15
- typeof FormController<TShape>
16
- >[0];
17
-
18
- export interface PreventableEvent {
19
- preventDefault(): void;
20
- }
21
-
22
- export type SubmitSuccessHandler<
23
- TShape extends object,
24
- TEvent extends PreventableEvent
25
- > = (
26
- data: TShape,
27
- event: TEvent,
28
- abortSignal: AbortSignal
29
- ) => void | Promise<void>;
30
-
31
- export type SubmitErrorHandler<TEvent extends PreventableEvent> = (
32
- issues: StandardSchemaV1.Issue[],
33
- event: TEvent,
34
- abortSignal: AbortSignal
35
- ) => void | Promise<void>;
36
- }
37
-
38
- // TODO: Rename TShape to TOutput, as it represents the targetted data shape on successful submission cb
39
- export class FormController<TShape extends object> {
40
- _isValidating = false;
41
- _isSubmitting = false;
42
-
43
- _fields = new Map<Field.Paths<TShape>, FormField<TShape, any>>();
44
- _initialData: DeepPartial<TShape>;
45
- _data: DeepPartial<TShape>;
46
- _issues: StandardSchemaV1.Issue[] = [];
47
-
48
- equalityComparators?: Record<any, (a: any, b: any) => boolean>;
49
- validationSchema?: StandardSchemaV1<unknown, TShape>;
50
-
51
- public readonly events = createNanoEvents<{
52
- submissionStatusChange(isSubmitting: boolean): void;
53
- validationStatusChange(isValidating: boolean): void;
54
-
55
- fieldBound(fieldPath: Field.Paths<TShape>): void;
56
- fieldUnbound(fieldPath: Field.Paths<TShape>): void;
57
- fieldTouchUpdated(path: Field.Paths<TShape>): void;
58
- fieldDirtyUpdated(path: Field.Paths<TShape>): void;
59
- fieldIssuesUpdated(fieldPath: Field.Paths<TShape>): void;
60
- elementBound(fieldPath: Field.Paths<TShape>, el: HTMLElement): void;
61
- elementUnbound(fieldPath: Field.Paths<TShape>): void;
62
- validationTriggered(fieldPath: Field.Paths<TShape>): void;
63
- valueChanged(
64
- fieldPath: Field.Paths<TShape>,
65
- newValue: Field.GetValue<TShape, Field.Paths<TShape>> | undefined,
66
- oldValue: Field.GetValue<TShape, Field.Paths<TShape>> | undefined
67
- ): void;
68
- }>();
69
-
70
- constructor(config: {
71
- initialData?: DeepPartial<TShape>;
72
- validationSchema?: StandardSchemaV1<unknown, TShape>;
73
- equalityComparators?: Record<any, (a: any, b: any) => boolean>;
74
- }) {
75
- this.validationSchema = config.validationSchema;
76
- this.equalityComparators = config.equalityComparators;
77
- this._initialData = config.initialData ?? ({} as DeepPartial<TShape>);
78
- this._data = produce(this._initialData, () => {});
79
- }
80
-
81
- get isDirty() {
82
- for (const field of this._fields.values()) {
83
- if (field.isDirty) return true;
84
- }
85
- return false;
86
- }
87
-
88
- get isValid() {
89
- // TODO: Does it still count valid while validating?
90
- return this._issues.length === 0;
91
- }
92
-
93
- get isValidating() {
94
- return this._isValidating;
95
- }
96
-
97
- get isSubmitting() {
98
- return this._isSubmitting;
99
- }
100
-
101
- protected setValidating(newStatus: boolean) {
102
- if (this._isValidating === newStatus) return;
103
- this._isValidating = newStatus;
104
- this.events.emit("validationStatusChange", newStatus);
105
- }
106
-
107
- protected setSubmitting(newStatus: boolean) {
108
- if (this._isSubmitting === newStatus) return;
109
- this._isSubmitting = newStatus;
110
- this.events.emit("submissionStatusChange", newStatus);
111
- }
112
-
113
- _unsafeSetFieldValue<TPath extends Field.Paths<TShape>>(
114
- path: TPath,
115
- value: Field.GetValue<TShape, TPath>,
116
- config?: { updateInitialValue?: boolean }
117
- ) {
118
- ensureImmerability(value);
119
-
120
- if (config?.updateInitialValue === true) {
121
- this._initialData = produce(this._initialData, (draft) => {
122
- Field.setValue(draft as TShape, path, value);
123
- });
124
- }
125
- this._data = produce(this._data, (draft) => {
126
- Field.setValue(draft as TShape, path, value);
127
- });
128
- }
129
-
130
- // TODO: Rename to "register" ??
131
- bindField<TPath extends Field.Paths<TShape>>(
132
- path: TPath,
133
- config?: {
134
- defaultValue?: Field.GetValue<TShape, TPath>;
135
- domElement?: HTMLElement;
136
- overrideInitialValue?: boolean;
137
- }
138
- ) {
139
- let currentValue = Field.getValue(this._data as TShape, path);
140
-
141
- if (currentValue == null && config?.defaultValue != null) {
142
- this._unsafeSetFieldValue(path, config.defaultValue, {
143
- updateInitialValue: config.overrideInitialValue,
144
- });
145
- currentValue = Field.getValue(this._data as TShape, path);
146
- }
147
-
148
- const initialValue = Field.getValue(this._initialData as TShape, path);
149
-
150
- const field = new FormField(this, path, {
151
- isDirty: !Field.deepEqual(currentValue, initialValue),
152
- });
153
-
154
- if (config?.domElement != null) {
155
- field.bindElement(config.domElement);
156
- }
157
-
158
- this._fields.set(path, field);
159
- this.events.emit("fieldBound", path);
160
-
161
- return field;
162
- }
163
-
164
- unbindField(path: Field.Paths<TShape>) {
165
- this._fields.delete(path);
166
- this.events.emit("fieldUnbound", path);
167
- }
168
-
169
- // TODO: Add an option to keep dirty/touched fields as they are
170
- reset(newInitialData?: DeepPartial<TShape>) {
171
- this._data = this._initialData;
172
- this._issues = [];
173
-
174
- for (const field of this._fields.values()) {
175
- field.reset();
176
- }
177
-
178
- if (newInitialData != null) {
179
- this._initialData = newInitialData;
180
- this._data = produce(this._initialData, () => {});
181
- }
182
- }
183
-
184
- getAscendantFields<TPath extends Field.Paths<TShape>>(path: TPath) {
185
- const pathFragments = Field.parsePathFragments(path);
186
-
187
- const paths = pathFragments.map((_, i) => {
188
- return Field.parsePath(
189
- pathFragments.slice(0, i + 1)
190
- ) as Field.Paths<TShape>;
191
- });
192
-
193
- return paths.map((path) => this.getField(path)).filter((field) => !!field);
194
- }
195
-
196
- getField<TPath extends Field.Paths<TShape>>(path: TPath) {
197
- return this._fields.get(path) as FormField<TShape, TPath> | undefined;
198
- }
199
-
200
- clearFieldIssues<TPath extends Field.Paths<TShape>>(path: TPath) {
201
- this._issues = this._issues.filter((issue) => {
202
- if (issue.path == null) return true;
203
- const issuePath = Field.parsePath(issue.path);
204
- return issuePath !== path;
205
- });
206
- }
207
-
208
- private async applyValidation<TPath extends Field.Paths<TShape>>(
209
- _result: StandardSchemaV1.Result<TShape>,
210
- path: TPath
211
- ) {
212
- const diff = Field.diff(
213
- this._issues,
214
- _result.issues ?? [],
215
- Field.deepEqual,
216
- (issue) => {
217
- if (issue.path == null) return false;
218
- const issuePath = Field.parsePath(issue.path);
219
- return issuePath === path || Field.isDescendant(path, issuePath);
220
- }
221
- );
222
-
223
- removeBy(this._issues, (issue) => diff.removed.includes(issue));
224
-
225
- diff.added.forEach((issue) => this._issues.push(issue));
226
-
227
- if (diff.added.length !== 0 || diff.removed.length !== 0) {
228
- this.events.emit("fieldIssuesUpdated", path);
229
- }
230
- }
231
-
232
- async validateField<TPath extends Field.Paths<TShape>>(path: TPath) {
233
- if (this._isValidating) return;
234
-
235
- if (this.validationSchema == null) return;
236
-
237
- this.setValidating(true);
238
-
239
- if (this.getField(path) == null) this.bindField(path);
240
-
241
- const result = await this.validationSchema["~standard"].validate(
242
- this._data
243
- );
244
-
245
- this.events.emit("validationTriggered", path);
246
- this.applyValidation(result, path);
247
-
248
- this.setValidating(false);
249
- }
250
-
251
- async validateForm() {
252
- if (this._isValidating) return;
253
-
254
- if (this.validationSchema == null) return;
255
-
256
- this.setValidating(true);
257
-
258
- const result = await this.validationSchema["~standard"].validate(
259
- this._data
260
- );
261
-
262
- for (const path of this._fields.keys()) {
263
- this.events.emit("validationTriggered", path);
264
- this.applyValidation(result, path);
265
- }
266
-
267
- // Append non-registered issues too
268
- const diff = Field.diff(this._issues, result.issues ?? [], Field.deepEqual);
269
- diff.added.forEach((issue) => this._issues.push(issue));
270
-
271
- this.setValidating(false);
272
- }
273
-
274
- createSubmitHandler<TEvent extends Form.PreventableEvent>(
275
- onSuccess?: Form.SubmitSuccessHandler<TShape, TEvent>,
276
- onError?: Form.SubmitErrorHandler<TEvent>
277
- ) {
278
- return async (event: TEvent) => {
279
- if (event != null) {
280
- event.preventDefault();
281
- }
282
-
283
- if (this._isValidating) return;
284
- if (this._isSubmitting) return;
285
-
286
- const abortController = new AbortController();
287
-
288
- await this.validateForm();
289
-
290
- if (this._issues.length === 0) {
291
- this.setSubmitting(true);
292
- await onSuccess?.(this._data as TShape, event, abortController.signal);
293
- this.setSubmitting(false);
294
- return;
295
- }
296
-
297
- for (const issue of this._issues) {
298
- if (issue.path == null) continue;
299
- const fieldPath = Field.parsePath(issue.path) as Field.Paths<TShape>;
300
- const field = this.getField(fieldPath);
301
- if (field == null) continue;
302
- if (field.boundElement == null) continue;
303
- field.focus();
304
- break;
305
- }
306
- await onError?.(this._issues, event, abortController.signal);
307
- this.setSubmitting(false);
308
- };
309
- }
310
- }
1
+ import { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import { produce } from "immer";
3
+ import { createNanoEvents } from "nanoevents";
4
+ import { FieldPath } from "../field/FieldPath";
5
+ import { FieldPathBuilder } from "../field/FieldPathBuilder";
6
+ import { Reconsile } from "../field/Reconcile";
7
+ import { FormField } from "../form/FormField";
8
+ import { DeepPartial } from "../types/DeepPartial";
9
+ import { ensureImmerability } from "../utils/ensureImmerability";
10
+ import { removeBy } from "../utils/removeBy";
11
+
12
+ export namespace FormController {
13
+ export type Configs<TOutput extends object> = {
14
+ initialData?: DeepPartial<TOutput>;
15
+ validationSchema?: StandardSchemaV1<unknown, TOutput>;
16
+ equalityComparators?: Record<any, (a: any, b: any) => boolean>;
17
+ };
18
+
19
+ export interface PreventableEvent {
20
+ preventDefault(): void;
21
+ }
22
+
23
+ export type SubmitSuccessHandler<
24
+ TOutput extends object,
25
+ TEvent extends PreventableEvent,
26
+ > = (
27
+ data: TOutput,
28
+ event: TEvent,
29
+ abortSignal: AbortSignal,
30
+ ) => void | Promise<void>;
31
+
32
+ export type SubmitErrorHandler<TEvent extends PreventableEvent> = (
33
+ issues: StandardSchemaV1.Issue[],
34
+ event: TEvent,
35
+ abortSignal: AbortSignal,
36
+ ) => void | Promise<void>;
37
+ }
38
+
39
+ export class FormController<TOutput extends object> {
40
+ _isValidating = false;
41
+ _isSubmitting = false;
42
+
43
+ _fields = new Map<string, FormField<TOutput, any>>();
44
+ _initialData: DeepPartial<TOutput>;
45
+ _data: DeepPartial<TOutput>;
46
+ _issues: StandardSchemaV1.Issue[] = [];
47
+
48
+ equalityComparators?: Record<any, (a: any, b: any) => boolean>;
49
+ validationSchema?: StandardSchemaV1<unknown, TOutput>;
50
+
51
+ public readonly events = createNanoEvents<{
52
+ submissionStatusChange(isSubmitting: boolean): void;
53
+ validationStatusChange(isValidating: boolean): void;
54
+
55
+ fieldBound(fieldPath: FieldPath.Segments): void;
56
+ fieldUnbound(fieldPath: FieldPath.Segments): void;
57
+ fieldTouchUpdated(path: FieldPath.Segments): void;
58
+ fieldDirtyUpdated(path: FieldPath.Segments): void;
59
+ fieldIssuesUpdated(fieldPath: FieldPath.Segments): void;
60
+ elementBound(fieldPath: FieldPath.Segments, el: HTMLElement): void;
61
+ elementUnbound(fieldPath: FieldPath.Segments): void;
62
+ validationTriggered(fieldPath: FieldPath.Segments): void;
63
+ valueChanged(
64
+ fieldPath: FieldPath.Segments,
65
+ newValue: {} | undefined,
66
+ oldValue: {} | undefined,
67
+ ): void;
68
+ }>();
69
+
70
+ constructor(config: FormController.Configs<TOutput>) {
71
+ this.validationSchema = config.validationSchema;
72
+ this.equalityComparators = config.equalityComparators;
73
+ this._initialData = config.initialData ?? ({} as DeepPartial<TOutput>);
74
+ this._data = produce(this._initialData, () => {});
75
+ }
76
+
77
+ get isDirty() {
78
+ for (const field of this._fields.values()) {
79
+ if (field.isDirty) return true;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ get isValid() {
85
+ // TODO: Does it still count valid while validating?
86
+ return this._issues.length === 0;
87
+ }
88
+
89
+ get isValidating() {
90
+ return this._isValidating;
91
+ }
92
+
93
+ get isSubmitting() {
94
+ return this._isSubmitting;
95
+ }
96
+
97
+ protected setValidating(newStatus: boolean) {
98
+ if (this._isValidating === newStatus) return;
99
+ this._isValidating = newStatus;
100
+ this.events.emit("validationStatusChange", newStatus);
101
+ }
102
+
103
+ protected setSubmitting(newStatus: boolean) {
104
+ if (this._isSubmitting === newStatus) return;
105
+ this._isSubmitting = newStatus;
106
+ this.events.emit("submissionStatusChange", newStatus);
107
+ }
108
+
109
+ _unsafeSetFieldValue<TPath extends FieldPath.Segments>(
110
+ path: TPath,
111
+ value: FieldPath.Resolve<TOutput, TPath>,
112
+ config?: { updateInitialValue?: boolean },
113
+ ) {
114
+ ensureImmerability(value);
115
+
116
+ if (config?.updateInitialValue === true) {
117
+ this._initialData = produce(this._initialData, (draft) => {
118
+ FieldPath.setValue(draft as TOutput, path, value);
119
+ });
120
+ }
121
+
122
+ this._data = produce(this._data, (draft) => {
123
+ FieldPath.setValue(draft as TOutput, path, value);
124
+ });
125
+ }
126
+
127
+ // TODO: Rename to "register" ??
128
+ bindField<TPath extends FieldPath.Segments>(
129
+ path: TPath,
130
+ config?: {
131
+ defaultValue?: FieldPath.Resolve<TOutput, TPath>;
132
+ domElement?: HTMLElement;
133
+ overrideInitialValue?: boolean;
134
+ },
135
+ ) {
136
+ let currentValue = FieldPath.getValue(this._data as TOutput, path);
137
+
138
+ if (currentValue == null && config?.defaultValue != null) {
139
+ this._unsafeSetFieldValue(path, config.defaultValue, {
140
+ updateInitialValue: config.overrideInitialValue,
141
+ });
142
+ currentValue = FieldPath.getValue(this._data as TOutput, path);
143
+ }
144
+
145
+ const initialValue = FieldPath.getValue(this._initialData as TOutput, path);
146
+
147
+ const field = new FormField(this, path, {
148
+ isDirty: !Reconsile.deepEqual(currentValue, initialValue),
149
+ });
150
+
151
+ if (config?.domElement != null) {
152
+ field.bindElement(config.domElement);
153
+ }
154
+
155
+ this._fields.set(field.stringPath, field);
156
+ this.events.emit("fieldBound", field.path);
157
+
158
+ return field as FormField<TOutput, FieldPath.Resolve<TOutput, TPath>>;
159
+ }
160
+
161
+ unbindField(path: FieldPath.Segments) {
162
+ const stringPath = FieldPath.toStringPath(path);
163
+ this._fields.delete(stringPath);
164
+ this.events.emit("fieldUnbound", path);
165
+ }
166
+
167
+ // TODO: Add an option to keep dirty/touched fields as they are
168
+ reset(newInitialData?: DeepPartial<TOutput>) {
169
+ this._data = this._initialData;
170
+ this._issues = [];
171
+
172
+ for (const field of this._fields.values()) {
173
+ field.reset();
174
+ }
175
+
176
+ if (newInitialData != null) {
177
+ this._initialData = newInitialData;
178
+ this._data = produce(this._initialData, () => {});
179
+ }
180
+ }
181
+
182
+ getAscendantFields<TPath extends FieldPath.Segments>(path: TPath) {
183
+ const paths = path.map((_, i) => {
184
+ return path.slice(0, i + 1);
185
+ });
186
+
187
+ return paths.map((path) => this.getField(path)).filter((field) => !!field);
188
+ }
189
+
190
+ getField<TPath extends FieldPath.Segments>(path: TPath) {
191
+ const stringPath = FieldPath.toStringPath(path);
192
+ return this._fields.get(stringPath) as
193
+ | FormField<TOutput, FieldPath.Resolve<TOutput, TPath>>
194
+ | undefined;
195
+ }
196
+
197
+ clearFieldIssues<TPath extends FieldPath.Segments>(path: TPath) {
198
+ this._issues = this._issues.filter((issue) => {
199
+ return !FieldPath.equals(FieldPath.normalize(issue.path), path);
200
+ });
201
+ }
202
+
203
+ private async applyValidation<TPath extends FieldPath.Segments>(
204
+ _result: StandardSchemaV1.Result<TOutput>,
205
+ path: TPath,
206
+ ) {
207
+ const diff = Reconsile.diff(
208
+ this._issues,
209
+ _result.issues ?? [],
210
+ Reconsile.deepEqual,
211
+ (issue) => {
212
+ if (issue.path == null) return false;
213
+ const issuePath = FieldPath.normalize(issue.path);
214
+ return (
215
+ FieldPath.equals(issuePath, path) ||
216
+ FieldPath.isDescendant(path, issuePath)
217
+ );
218
+ },
219
+ );
220
+
221
+ removeBy(this._issues, (issue) => diff.removed.includes(issue));
222
+
223
+ diff.added.forEach((issue) => this._issues.push(issue));
224
+
225
+ if (diff.added.length !== 0 || diff.removed.length !== 0) {
226
+ this.events.emit("fieldIssuesUpdated", path);
227
+ }
228
+ }
229
+
230
+ async validateField<TPath extends FieldPath.Segments>(path: TPath) {
231
+ if (this._isValidating) return;
232
+
233
+ if (this.validationSchema == null) return;
234
+
235
+ this.setValidating(true);
236
+
237
+ if (this.getField(path) == null) this.bindField(path);
238
+
239
+ const result = await this.validationSchema["~standard"].validate(
240
+ this._data,
241
+ );
242
+
243
+ this.events.emit("validationTriggered", path);
244
+ this.applyValidation(result, path);
245
+
246
+ this.setValidating(false);
247
+ }
248
+
249
+ async validateForm() {
250
+ if (this._isValidating) return;
251
+
252
+ if (this.validationSchema == null) return;
253
+
254
+ this.setValidating(true);
255
+
256
+ const result = await this.validationSchema["~standard"].validate(
257
+ this._data,
258
+ );
259
+
260
+ for (const stringPath of this._fields.keys()) {
261
+ const path = FieldPath.fromStringPath(stringPath);
262
+ this.events.emit("validationTriggered", path);
263
+ this.applyValidation(result, path);
264
+ }
265
+
266
+ // Append non-registered issues too
267
+ const diff = Reconsile.diff(
268
+ this._issues,
269
+ result.issues ?? [],
270
+ Reconsile.deepEqual,
271
+ );
272
+ diff.added.forEach((issue) => this._issues.push(issue));
273
+
274
+ this.setValidating(false);
275
+ }
276
+
277
+ createSubmitHandler<TEvent extends FormController.PreventableEvent>(
278
+ onSuccess?: FormController.SubmitSuccessHandler<TOutput, TEvent>,
279
+ onError?: FormController.SubmitErrorHandler<TEvent>,
280
+ ) {
281
+ return async (event: TEvent) => {
282
+ if (event != null) {
283
+ event.preventDefault();
284
+ }
285
+
286
+ if (this._isValidating) return;
287
+ if (this._isSubmitting) return;
288
+
289
+ // TODO? impl or cancel
290
+ const abortController = new AbortController();
291
+
292
+ await this.validateForm();
293
+
294
+ if (this._issues.length === 0) {
295
+ this.setSubmitting(true);
296
+ await onSuccess?.(this._data as TOutput, event, abortController.signal);
297
+ this.setSubmitting(false);
298
+ return;
299
+ }
300
+
301
+ for (const issue of this._issues) {
302
+ if (issue.path == null) continue;
303
+ const issuePath = FieldPath.normalize(issue.path);
304
+ const field = this.getField(issuePath);
305
+ if (field == null) continue;
306
+ if (field.boundElement == null) continue;
307
+ field.focus();
308
+ break;
309
+ }
310
+ await onError?.(this._issues, event, abortController.signal);
311
+ this.setSubmitting(false);
312
+ };
313
+ }
314
+ }
315
+
316
+ /* ---- TESTS ---------------- */
317
+
318
+ // interface User {
319
+ // name: string;
320
+ // address: {
321
+ // city: string;
322
+ // street: string;
323
+ // };
324
+ // friends: {
325
+ // name: string;
326
+ // tags: string[];
327
+ // }[];
328
+ // coords: [100, 200];
329
+ // }
330
+
331
+ // const formController = new FormController<User>({});
332
+ // const fieldPathBuilder = new FieldPathBuilder<User>();
333
+
334
+ // formController.events.on("valueChanged", (fieldPath, value) => {
335
+ // // ^?
336
+ // });
337
+
338
+ // const path1 = fieldPathBuilder.fromProxy((data) => data.friends[0].tags[99]);
339
+ // const field1 = formController.getField(path1);
340
+
341
+ // const path2 = fieldPathBuilder.fromStringPath("coords[1]");
342
+ // const field2 = formController.getField(path2);