notform 3.0.1 → 4.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +14 -186
  2. package/dist/index.d.ts +459 -475
  3. package/dist/index.js +1103 -790
  4. package/package.json +19 -22
package/dist/index.js CHANGED
@@ -1,945 +1,1258 @@
1
- import { Fragment, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, defineComponent, guardReactiveProps, inject, mergeProps, nextTick, normalizeProps, onMounted, onUnmounted, openBlock, provide, reactive, ref, renderSlot, resolveDynamicComponent, toDisplayString, toValue, unref, watch, withCtx, withModifiers } from "vue";
2
- import { deepKeys, deleteProperty, getProperty, parsePath, setProperty, stringifyPath } from "dot-prop";
1
+ import { computed, createBlock, createCommentVNode, createElementBlock, createTextVNode, defineComponent, getCurrentScope, mergeProps, normalizeProps, onScopeDispose, openBlock, reactive, readonly, ref, renderSlot, resolveDynamicComponent, shallowReactive, shallowRef, toDisplayString, toRaw, toValue, triggerRef, watch, withCtx, withModifiers } from "vue";
3
2
  import { dequal } from "dequal";
4
3
  import { klona } from "klona";
5
- //#region src/utils/segments.ts
4
+ import { getProperty, parsePath, setProperty } from "dot-prop";
5
+ // src/components/array-field.vue
6
+ var array_field_default = /* @__PURE__ */ defineComponent({
7
+ inheritAttrs: false,
8
+ __name: "array-field",
9
+ props: { arrayField: {
10
+ type: Object,
11
+ required: true
12
+ } },
13
+ setup(__props) {
14
+ /** `props.arrayField` is the field created by `form.arrayField('some.path')`. */
15
+ const props = __props;
16
+ return (_ctx, _cache) => {
17
+ return renderSlot(_ctx.$slots, "default", mergeProps({ arrayField: props.arrayField }, _ctx.$attrs));
18
+ };
19
+ }
20
+ });
21
+ // src/components/field-error.vue
22
+ var field_error_default = /* @__PURE__ */ defineComponent({
23
+ inheritAttrs: false,
24
+ __name: "field-error",
25
+ props: {
26
+ as: {
27
+ type: null,
28
+ required: false,
29
+ default: "span"
30
+ },
31
+ errors: {
32
+ type: [Array, Object],
33
+ required: true
34
+ }
35
+ },
36
+ setup(__props) {
37
+ const props = __props;
38
+ const message = computed(() => {
39
+ if (Array.isArray(props.errors)) return props.errors[0]?.message;
40
+ return props.errors?.errors?.[0]?.message;
41
+ });
42
+ return (_ctx, _cache) => {
43
+ return message.value ? (openBlock(), createBlock(resolveDynamicComponent(props.as), normalizeProps(mergeProps({ key: 0 }, _ctx.$attrs)), {
44
+ default: withCtx(() => [renderSlot(_ctx.$slots, "default", { message: message.value }, () => [createTextVNode(toDisplayString(message.value), 1)])]),
45
+ _: 3
46
+ }, 16)) : createCommentVNode("v-if", true);
47
+ };
48
+ }
49
+ });
50
+ // src/components/field.vue
51
+ var field_default = /* @__PURE__ */ defineComponent({
52
+ inheritAttrs: false,
53
+ __name: "field",
54
+ props: { field: {
55
+ type: Object,
56
+ required: true
57
+ } },
58
+ setup(__props) {
59
+ /** `props.field` is the field created by `form.field('some.path')`, or an array item. */
60
+ const props = __props;
61
+ return (_ctx, _cache) => {
62
+ return renderSlot(_ctx.$slots, "default", mergeProps({ field: props.field }, _ctx.$attrs));
63
+ };
64
+ }
65
+ });
66
+ // src/components/form.vue
67
+ var form_default = /* @__PURE__ */ defineComponent({
68
+ inheritAttrs: false,
69
+ __name: "form",
70
+ props: { form: {
71
+ type: Object,
72
+ required: true
73
+ } },
74
+ setup(__props) {
75
+ /** `props.form` is the form returned by `useForm()`. */
76
+ const props = __props;
77
+ return (_ctx, _cache) => {
78
+ return openBlock(), createElementBlock("form", mergeProps(_ctx.$attrs, {
79
+ onSubmit: _cache[0] || (_cache[0] = (...args) => props.form.submit && props.form.submit(...args)),
80
+ onReset: _cache[1] || (_cache[1] = withModifiers((...args) => props.form.reset && props.form.reset(...args), ["prevent"]))
81
+ }), [renderSlot(_ctx.$slots, "default")], 16);
82
+ };
83
+ }
84
+ });
85
+ // src/utils/path.ts
86
+ /**
87
+ * @file
88
+ * Everything that reads, writes, splits or compares paths lives here, so the
89
+ * rest of the code never touches the path library (dot-prop) directly.
90
+ *
91
+ * - PATH dot-notation text, e.g. 'address.city' or 'tags.0'
92
+ * - SEGMENTS the same location split into steps, e.g. ['address', 'city'] or ['tags', 0]
93
+ */
6
94
  /**
7
- * Unwraps a Standard Schema path segment to a property key.
8
- * @internal
9
- * @param segment Plain key or `{ key }`.
10
- * @returns The underlying property key.
95
+ * Reads a path step as an array index.
96
+ * @param segment A path step: `0`, `'0'`, `{ key: 0 }`, ...
97
+ * @returns The index, or `undefined` if the step is not a non-negative integer.
98
+ * @example
99
+ * getSegmentIndex('2') // 2
100
+ * getSegmentIndex('name') // undefined
11
101
  */
12
- function toPropertyKey(segment) {
102
+ function getSegmentIndex(segment) {
103
+ const key = getSegmentKey(segment);
104
+ if (typeof key === "number") return Number.isSafeInteger(key) && key >= 0 ? key : void 0;
105
+ return typeof key === "string" && /^(?:0|[1-9]\d*)$/.test(key) ? Number(key) : void 0;
106
+ }
107
+ /**
108
+ * Reads the key out of a path step, whether it is bare or wrapped.
109
+ * @param segment A path step: `'email'`, `0`, or `{ key: 'email' }`.
110
+ * @returns The plain key.
111
+ * @example
112
+ * getSegmentKey({ key: 'email' }) // 'email'
113
+ */
114
+ function getSegmentKey(segment) {
13
115
  if (typeof segment === "object" && segment !== null && "key" in segment) return segment.key;
14
116
  return segment;
15
117
  }
16
118
  /**
17
- * Whether two path segments name the same property. `0` and `"0"` are equal.
18
- * @internal
19
- * @param segmentA First segment.
20
- * @param segmentB Second segment.
21
- * @returns `true` when both refer to the same key.
119
+ * Reads the value at a dot-notation path.
120
+ * @param source The object to read from.
121
+ * @param path Dot-notation path, e.g. `'tags.0'`.
122
+ * @returns The value, or `undefined` if any step is missing.
123
+ */
124
+ function getValueAtPath(source, path) {
125
+ return getProperty(source, path);
126
+ }
127
+ /**
128
+ * Checks whether a path sits at, or below, a parent path.
129
+ * @param segments The path being tested.
130
+ * @param parentSegments The path it should start with.
131
+ * @returns `true` if `segments` equals `parentSegments` or is nested deeper.
132
+ * @example
133
+ * isPathInside(['user', 'name'], ['user']) // true
134
+ * isPathInside(['user'], ['user']) // true
135
+ * isPathInside(['user'], ['user', 'name']) // false
22
136
  */
23
- function areSegmentsEqual(segmentA, segmentB) {
24
- const keyA = toPropertyKey(segmentA);
25
- const keyB = toPropertyKey(segmentB);
26
- if (typeof keyA === "number" && typeof keyB === "string") return Number.isSafeInteger(Number(keyB)) && keyA === Number(keyB);
27
- if (typeof keyA === "string" && typeof keyB === "number") return Number.isSafeInteger(Number(keyA)) && Number(keyA) === keyB;
28
- return keyA === keyB;
137
+ function isPathInside(segments, parentSegments) {
138
+ if (segments.length < parentSegments.length) return false;
139
+ return parentSegments.every((parentSegment, index) => {
140
+ return isSameSegment(segments[index], parentSegment);
141
+ });
142
+ }
143
+ /**
144
+ * Checks whether two paths point at exactly the same place.
145
+ * @param firstSegments The first path (e.g. an issue's `path`).
146
+ * @param secondSegments The second path.
147
+ * @returns `true` if both exist, have the same length, and every step matches.
148
+ * @remarks A missing path means "the whole form", so it never equals anything, not even another missing path.
149
+ * @example
150
+ * isSamePath(['tags', 0], ['tags', '0']) // true
151
+ * isSamePath(undefined, undefined) // false
152
+ */
153
+ function isSamePath(firstSegments, secondSegments) {
154
+ if (!firstSegments || !secondSegments || firstSegments.length !== secondSegments.length) return false;
155
+ return firstSegments.every((segment, index) => {
156
+ return isSameSegment(segment, secondSegments[index]);
157
+ });
29
158
  }
30
159
  /**
31
- * Whether `pathSegments` names the same field as `scopeSegments`, or a field
32
- * nested underneath it, at any depth.
160
+ * Checks whether two path steps are the same key.
33
161
  *
34
- * This is the general **"at-or-under"** test: `isPathWithinScope(path, scope)`
35
- * is `true` when `path` equals `scope` exactly, or when `path` extends
36
- * `scope` with one or more extra trailing segments. It underlies both
37
- * {@linkcode locatePathInArrayField} (which additionally requires at least one
38
- * extra segment, since it locates a specific item) and the recursive
39
- * `isValid`/`isTouched`/`isDirty`/`isValidating` aggregates on
40
- * `<NotArrayField>`, which allow zero extra segments too, since the array's
41
- * own path should count toward its own aggregate.
42
- * @template TPathSegment Segment type of the candidate path.
43
- * @template TScopeSegment Segment type of the scope path.
44
- * @internal
45
- * @param pathSegments Candidate path, already split.
46
- * @param scopeSegments Scope path, already split.
47
- * @returns `true` when `pathSegments` is `scopeSegments` or a descendant of it.
48
- */
49
- function isPathWithinScope(pathSegments, scopeSegments) {
50
- if (pathSegments.length < scopeSegments.length) return false;
51
- return scopeSegments.every((scopeSegment, segmentIndex) => areSegmentsEqual(pathSegments[segmentIndex], scopeSegment));
52
- }
53
- //#endregion
54
- //#region src/utils/array-field.ts
55
- /**
56
- * Locates a field path inside an array field, e.g. `users.1.email` under
57
- * `users`, or `users.1.addresses.0.city` under `users` — `remainingPathSegments`
58
- * holds whatever comes after the index, however many segments deep that is.
59
- * @template TPathSegment Segment type of the candidate path.
60
- * @template TArraySegment Segment type of the array field path.
61
- * @internal
62
- * @param pathSegments Candidate path, already split.
63
- * @param arrayFieldPathSegments Array field path, already split.
64
- * @returns Item index and leftover segments, or `undefined` when the path is not inside the array.
65
- */
66
- function locatePathInArrayField(pathSegments, arrayFieldPathSegments) {
67
- if (pathSegments.length <= arrayFieldPathSegments.length || !isPathWithinScope(pathSegments, arrayFieldPathSegments)) return;
68
- const itemIndexSegment = toPropertyKey(pathSegments[arrayFieldPathSegments.length]);
69
- if (typeof itemIndexSegment !== "number" && typeof itemIndexSegment !== "string") return;
70
- const itemIndex = Number(itemIndexSegment);
71
- if (!Number.isSafeInteger(itemIndex) || itemIndex < 0) return;
162
+ * `0` equals `'0'` because schema libraries disagree on whether array indexes
163
+ * are numbers or strings. Wrapped keys (`{ key }`) are unwrapped first.
164
+ * @param firstSegment The first step.
165
+ * @param secondSegment The second step.
166
+ * @returns `true` if both steps refer to the same key.
167
+ */
168
+ function isSameSegment(firstSegment, secondSegment) {
169
+ const firstKey = getSegmentKey(firstSegment);
170
+ const secondKey = getSegmentKey(secondSegment);
171
+ if (typeof firstKey === "number" && typeof secondKey === "string") return Number.isSafeInteger(firstKey) && String(firstKey) === secondKey;
172
+ if (typeof firstKey === "string" && typeof secondKey === "number") return Number.isSafeInteger(secondKey) && firstKey === String(secondKey);
173
+ return firstKey === secondKey;
174
+ }
175
+ /**
176
+ * Appends one step to a path.
177
+ * @param parentPath The parent path. An empty string means the form root.
178
+ * @param key The step to append (property name or array index).
179
+ * @returns The combined path.
180
+ * @example
181
+ * joinPath('tags', 1) // 'tags.1'
182
+ * joinPath('', 'name') // 'name'
183
+ */
184
+ function joinPath(parentPath, key) {
185
+ return parentPath === "" ? String(key) : `${parentPath}.${key}`;
186
+ }
187
+ /**
188
+ * Writes a value at a dot-notation path, creating missing steps.
189
+ * @param target The object to write into (modified in place).
190
+ * @param path Dot-notation path, e.g. `'address.city'`.
191
+ * @param value The value to write.
192
+ */
193
+ function setValueAtPath(target, path, value) {
194
+ setProperty(target, path, value);
195
+ }
196
+ /**
197
+ * Splits a dot-notation path into its steps.
198
+ * @param path Dot-notation path, e.g. `'tags.0.name'`.
199
+ * @returns The steps, e.g. `['tags', '0', 'name']` (indexes may be strings or numbers).
200
+ */
201
+ function splitPath(path) {
202
+ return parsePath(path);
203
+ }
204
+ // src/utils/validation-issue.ts
205
+ /** Message used when something was thrown that carries no readable text. */
206
+ const FALLBACK_VALIDATION_ERROR_MESSAGE = "Validation failed";
207
+ /**
208
+ * Turns anything thrown during validation into a regular issue, so a crashing
209
+ * schema shows up as a form error instead of an unhandled exception.
210
+ * @param error Whatever was thrown.
211
+ * @param path Dot-notation path of the field being validated. Leave out for a form-level error.
212
+ * @returns An issue carrying the error's message.
213
+ * @example
214
+ * createValidationIssueFromError(new Error('Down'), 'email') // { message: 'Down', path: ['email'] }
215
+ */
216
+ function createValidationIssueFromError(error, path) {
217
+ const message = getErrorMessage(error);
218
+ return path ? {
219
+ message,
220
+ path: splitPath(path)
221
+ } : { message };
222
+ }
223
+ /**
224
+ * Re-points an issue that lives inside an array after the array was mutated.
225
+ * @param issue The issue to adjust.
226
+ * @param arraySegments Path of the mutated array.
227
+ * @param mapIndex Translates an item's old index to its new one.
228
+ * @returns The same issue when it is unaffected, a copy with the new index when the
229
+ * item moved, or `undefined` when the item no longer exists.
230
+ */
231
+ function remapIssueIndex(issue, arraySegments, mapIndex) {
232
+ const { path } = issue;
233
+ if (!path || path.length <= arraySegments.length || !isPathInside(path, arraySegments)) return issue;
234
+ const oldIndex = getSegmentIndex(path[arraySegments.length]);
235
+ if (oldIndex === void 0) return issue;
236
+ const newIndex = mapIndex(oldIndex);
237
+ if (newIndex === void 0) return;
238
+ if (newIndex === oldIndex) return issue;
72
239
  return {
73
- index: itemIndex,
74
- remainingPathSegments: pathSegments.slice(arrayFieldPathSegments.length + 1)
240
+ ...issue,
241
+ path: [
242
+ ...path.slice(0, arraySegments.length),
243
+ newIndex,
244
+ ...path.slice(arraySegments.length + 1)
245
+ ]
75
246
  };
76
247
  }
77
248
  /**
78
- * Rewrites paths in a set after an array mutation. Paths outside the array are left as-is.
79
- * @template TSchema The form schema.
80
- * @internal
81
- * @param paths Touched or dirty path set.
82
- * @param arraySegments Split array field path.
83
- * @param remapIndex Previous item index → next index, or `undefined` if removed.
84
- */
85
- function remapPathSet(paths, arraySegments, remapIndex) {
86
- const pathsToRemove = [];
87
- const pathsToAdd = [];
88
- for (const path of paths) {
89
- const location = locatePathInArrayField(parsePath(path), arraySegments);
90
- if (!location) continue;
91
- pathsToRemove.push(path);
92
- const nextIndex = remapIndex(location.index);
93
- if (nextIndex === void 0) continue;
94
- const nextPath = stringifyPath([
95
- ...arraySegments,
96
- nextIndex,
97
- ...location.remainingPathSegments
98
- ], { preferDotForIndices: true });
99
- pathsToAdd.push(nextPath);
100
- }
101
- for (const path of pathsToRemove) paths.delete(path);
102
- for (const path of pathsToAdd) paths.add(path);
103
- }
104
- /**
105
- * Moves touched, dirty, and error state with array items after a structural
106
- * mutation. Because {@linkcode locatePathInArrayField} keeps whatever follows the
107
- * item's index as `remainingPathSegments`, this already handles state nested
108
- * arbitrarily deep inside an item — a field on an object item, or an item
109
- * inside a nested array of its own — not just the item's own top-level path.
249
+ * Extracts readable text from a thrown value.
250
+ * @param error Whatever was thrown.
251
+ * @returns The `Error` message, the string itself, or a generic fallback.
252
+ */
253
+ function getErrorMessage(error) {
254
+ if (error instanceof Error) return error.message;
255
+ if (typeof error === "string") return error;
256
+ return FALLBACK_VALIDATION_ERROR_MESSAGE;
257
+ }
258
+ // src/utils/validation-scope.ts
259
+ /**
260
+ * Checks whether a new run makes an older run pointless.
110
261
  *
111
- * Validating state is not remapped; generation tracking already drops stale results.
112
- * @template TSchema The form schema.
113
- * @internal
114
- * @param form Full form instance.
115
- * @param arrayPath Dot path of the array field.
116
- * @param remapIndex Previous item index → next index, or `undefined` if removed.
117
- */
118
- function remapArrayFieldState(form, arrayPath, remapIndex) {
119
- const arraySegments = parsePath(arrayPath);
120
- remapPathSet(form.touchedFields, arraySegments, remapIndex);
121
- remapPathSet(form.dirtyFields, arraySegments, remapIndex);
122
- const remappedErrors = [];
123
- for (const issue of form.errors) {
124
- if (!issue.path) {
125
- remappedErrors.push(issue);
126
- continue;
127
- }
128
- const location = locatePathInArrayField(issue.path, arraySegments);
129
- if (!location) {
130
- remappedErrors.push(issue);
131
- continue;
132
- }
133
- const nextIndex = remapIndex(location.index);
134
- if (nextIndex === void 0) continue;
135
- remappedErrors.push({
136
- ...issue,
137
- path: [
138
- ...arraySegments,
139
- nextIndex,
140
- ...location.remainingPathSegments
141
- ]
142
- });
143
- }
144
- form.replaceErrors(remappedErrors);
262
+ * A field run only writes the errors of its own path, so it only replaces an
263
+ * older run of that same path. A whole-form run writes everything, so it
264
+ * replaces any older run.
265
+ * @param coveringScope Scope of the new run.
266
+ * @param coveredScope Scope of the older run.
267
+ * @returns `true` if the older run's result would be overwritten anyway.
268
+ */
269
+ function doesScopeCover(coveringScope, coveredScope) {
270
+ if (coveringScope.type === "form") return true;
271
+ return coveredScope.type === "field" && isSamePath(coveringScope.segments, coveredScope.segments);
145
272
  }
146
- //#endregion
147
- //#region src/utils/instance.ts
148
273
  /**
149
- * Injection key for the full {@linkcode NotFormInstance}.
150
- * @internal
274
+ * Builds the scope of a validation run.
275
+ * @param path Dot-notation field path. Leave out to cover the whole form.
276
+ * @returns A field scope when `path` is given, otherwise a whole-form scope.
277
+ * @example
278
+ * getValidationScope('tags.0') // { segments: ['tags', '0'], type: 'field' }
151
279
  */
152
- const NOT_FORM_INSTANCE_KEY = Symbol("notform:instance");
280
+ function getValidationScope(path) {
281
+ if (path === void 0) return { type: "form" };
282
+ return {
283
+ segments: splitPath(path),
284
+ type: "field"
285
+ };
286
+ }
153
287
  /**
154
- * Provides the full form instance to descendants.
155
- * @template TSchema The form schema.
156
- * @internal
157
- * @param instance Public API or full instance.
288
+ * Checks whether a running validation concerns a field, for `isValidating` flags.
289
+ * A field counts as validating while the whole form, the field itself, or any
290
+ * field nested inside it is being validated.
291
+ * @param runScope Scope of the running validation.
292
+ * @param fieldSegments Path of the field being asked about.
293
+ * @returns `true` if the run concerns the field.
158
294
  */
159
- function provideNotFormInstance(instance) {
160
- provide(NOT_FORM_INSTANCE_KEY, toNotFormInstance(instance));
295
+ function isFieldAffectedByScope(runScope, fieldSegments) {
296
+ if (runScope.type === "form") return true;
297
+ return isPathInside(runScope.segments, fieldSegments);
161
298
  }
162
- function toNotFormInstance(form) {
163
- return form;
299
+ // src/utils/array.ts
300
+ /**
301
+ * Forces an index into `0..max` (inclusive). `NaN` becomes `0`, `Infinity` becomes `max`.
302
+ * @param index The requested index.
303
+ * @param max The highest allowed index.
304
+ * @returns The clamped integer index.
305
+ */
306
+ function clampIndex(index, max) {
307
+ return Math.min(Math.max(Math.trunc(index) || 0, 0), max);
164
308
  }
165
- //#endregion
166
- //#region src/composables/use-not-form-instance.ts
167
309
  /**
168
- * Resolves the full form instance from `explicitInstance` or inject.
169
- * @template TSchema The form schema.
170
- * @internal
171
- * @param explicitInstance Optional `form` prop, preferred over inject.
172
- * @returns The resolved instance.
173
- * @throws If neither prop nor inject is available.
310
+ * Maps indexes after an item was replaced in place: the replaced item is gone, the rest stay.
311
+ * @param at Index of the replaced item.
312
+ * @returns The mapper.
174
313
  */
175
- function useNotFormInstance(explicitInstance) {
176
- const injectedInstance = inject(NOT_FORM_INSTANCE_KEY, void 0);
177
- const resolvedInstance = toNotFormInstance(explicitInstance) ?? injectedInstance;
178
- if (!resolvedInstance) throw new Error(`
179
- [NotForm] No NotForm instance found.
180
- Wrap with <NotForm :form="[instance]"> or pass :form="[instance]" directly to the component.
181
- `);
182
- return resolvedInstance;
314
+ function createDiscardMapper(at) {
315
+ return (index) => {
316
+ return index === at ? void 0 : index;
317
+ };
183
318
  }
184
- //#endregion
185
- //#region src/composables/use-not-array-field.ts
186
319
  /**
187
- * Array-field state, stable item keys, and mutation helpers for `<NotArrayField>`.
320
+ * Maps indexes after an item was inserted: everything from `at` on shifts up by one.
321
+ * @param at Index the new item was inserted at.
322
+ * @returns The mapper.
323
+ */
324
+ function createInsertMapper(at) {
325
+ return (index) => {
326
+ return index >= at ? index + 1 : index;
327
+ };
328
+ }
329
+ /**
330
+ * Maps indexes after an item moved from `from` to `to`.
331
+ * @param from Index the item left.
332
+ * @param to Index the item landed on.
333
+ * @returns The mapper.
334
+ */
335
+ function createMoveMapper(from, to) {
336
+ return (index) => {
337
+ if (index === from) return to;
338
+ if (from < to && index > from && index <= to) return index - 1;
339
+ if (from > to && index >= to && index < from) return index + 1;
340
+ return index;
341
+ };
342
+ }
343
+ /**
344
+ * Maps indexes after an item was removed: the removed item is gone, later ones shift down by one.
345
+ * @param at Index of the removed item.
346
+ * @returns The mapper.
347
+ */
348
+ function createRemoveMapper(at) {
349
+ return (index) => {
350
+ if (index === at) return;
351
+ return index > at ? index - 1 : index;
352
+ };
353
+ }
354
+ /**
355
+ * Maps indexes after two items traded places.
356
+ * @param indexA Index of the first item.
357
+ * @param indexB Index of the second item.
358
+ * @returns The mapper.
359
+ */
360
+ function createSwapMapper(indexA, indexB) {
361
+ return (index) => {
362
+ if (index === indexA) return indexB;
363
+ return index === indexB ? indexA : index;
364
+ };
365
+ }
366
+ /**
367
+ * Checks that an index points at an existing item.
368
+ * @param index The index to test.
369
+ * @param length The length of the list.
370
+ * @returns `true` for an integer in `0..length - 1`.
371
+ */
372
+ function isIndexInRange(index, length) {
373
+ return Number.isSafeInteger(index) && index >= 0 && index < length;
374
+ }
375
+ /**
376
+ * Moves one item inside a list, in place. Both indexes must be in range.
377
+ * @param list The list to change.
378
+ * @param from Current index of the item.
379
+ * @param to Index the item should end up at.
380
+ */
381
+ function moveListItem(list, from, to) {
382
+ const [item] = list.splice(from, 1);
383
+ list.splice(to, 0, item);
384
+ }
385
+ /**
386
+ * Swaps two items of a list, in place. Both indexes must be in range.
387
+ * @param list The list to change.
388
+ * @param indexA Index of the first item.
389
+ * @param indexB Index of the second item.
390
+ */
391
+ function swapListItems(list, indexA, indexB) {
392
+ const first = list[indexA];
393
+ list[indexA] = list[indexB];
394
+ list[indexB] = first;
395
+ }
396
+ // src/utils/id.ts
397
+ let lastIssuedNumber = 0;
398
+ /**
399
+ * Creates the unique, stable identity of a field.
188
400
  *
189
- * `itemKeys` is the only source of `item.key`. Length-only external changes
190
- * pad or trim keys from the end; use the mutation helpers to keep identity
191
- * through insert, remove, swap, and move.
401
+ * The number in the description is a debugging aid only; always compare ids by reference.
402
+ * @param kind What the id is for.
403
+ * @returns A new symbol, e.g. `notform.field.3`.
404
+ */
405
+ function createFieldId(kind) {
406
+ lastIssuedNumber += 1;
407
+ return Symbol(`notform.${kind}.${lastIssuedNumber}`);
408
+ }
409
+ // src/utils/debounce.ts
410
+ /**
411
+ * Cleans up a debounce delay so it is always safe to hand to `setTimeout`.
412
+ * @param delayInMilliseconds The delay requested by the user, if any.
413
+ * @returns `undefined` when no delay was given; otherwise the delay clamped to
414
+ * `0` or more (negative, `NaN` and `Infinity` all become `0`).
415
+ * @example
416
+ * normalizeDebounce(undefined) // undefined
417
+ * normalizeDebounce(-50) // 0
418
+ */
419
+ function normalizeDebounce(delayInMilliseconds) {
420
+ if (delayInMilliseconds === void 0) return;
421
+ return Number.isFinite(delayInMilliseconds) ? Math.max(0, delayInMilliseconds) : 0;
422
+ }
423
+ // src/factories/create-field.ts
424
+ /**
425
+ * Creates a field: a small reactive handle bound to one value inside a form.
426
+ * @template TValue - Type of the value the field holds.
427
+ * @param host The form the field belongs to.
428
+ * @param options Identity, path source and behavior.
429
+ * @returns The field instance and its teardown.
430
+ * @remarks Internal. Use `form.field(path)`, which caches the result.
431
+ */
432
+ function createField(host, options) {
433
+ const { dispose, parts } = createFieldState(host, options);
434
+ return {
435
+ dispose,
436
+ instance: reactive(parts)
437
+ };
438
+ }
439
+ /**
440
+ * Builds the state every kind of field shares (plain field, array field, array item).
192
441
  *
193
- * `isValid`/`isTouched`/`isDirty`/`isValidating` are recursive: they reflect
194
- * the array field's own path plus every path nested underneath it, at any
195
- * depth — an item's own value, a field inside an object item, or an item
196
- * inside a nested array. `errors`, by contrast, is intentionally exact: it
197
- * only ever reflects issues reported at the array field's own path, the same
198
- * way `<NotField>`'s `errors` only reflects issues at that one field's path.
199
- * @template TSchema The form schema.
200
- * @template TItemSchema Schema used only to type mutation values.
201
- * @internal
202
- * @param props Array field path, optional form, and optional item schema.
203
- * @returns Slot state for `<NotArrayField>`.
204
- */
205
- function useNotArrayField(props) {
206
- const form = useNotFormInstance(props.form);
207
- /** Monotonic id used by {@linkcode createItemKey}. */
208
- let nextItemKeyId = 0;
209
- /** Stable identity per current array index; mutated in lockstep with values. */
210
- const itemKeys = ref([]);
211
- /** Live array at `props.path`, or `[]` when the path is missing or not an array. */
212
- const arrayValue = computed(() => {
213
- const value = getProperty(form.values, props.path);
214
- return Array.isArray(value) ? value : [];
442
+ * The field owns no data: `model` reads and writes straight into the form's values,
443
+ * and the path is resolved on every read, so the field stays correct when it moves.
444
+ * @template TValue - Type of the value the field holds.
445
+ * @param host The form the field belongs to.
446
+ * @param options Identity, path source and behavior.
447
+ * @returns `parts`, the raw members (computed refs and functions) to wrap in `reactive()`, `dispose`,
448
+ * and `isDisposed`. A disposed field is inert: events, writes and validation do nothing.
449
+ */
450
+ function createFieldState(host, options) {
451
+ const id = options.id ?? createFieldId("field");
452
+ const config = resolveFieldConfig(options.config);
453
+ let isDisposed = false;
454
+ let lastKnownPath = "";
455
+ /** Current path. Frozen at its last value once the field is disposed. */
456
+ const path = computed(() => {
457
+ if (!isDisposed) lastKnownPath = options.getPath();
458
+ return lastKnownPath;
215
459
  });
216
- /** Slot items: current index, stored key, and index-based path. */
217
- const items = computed(() => arrayValue.value.map((_, index) => ({
218
- index,
219
- key: itemKeys.value[index] ?? "",
220
- path: `${props.path}.${index}`
221
- })));
222
- /** `props.path`, pre-split into segments, for the scope checks below. */
223
- const arrayPathSegments = computed(() => parsePath(props.path));
224
- /** Issues reported exactly at this array field's own path — never issues from items. */
225
- const errors = computed(() => form.getFieldErrors(props.path));
226
- /**
227
- * Whether the array field's own path, or any path nested under it — an
228
- * item, a field inside an object item, or an item inside a nested array —
229
- * has an active issue. Unlike `errors` above, this recurses to any depth.
230
- * Issues without a `path` are never attributed to a specific field, so
231
- * they don't affect this — only the top-level `form.isValid` reflects them.
232
- */
233
- const isValid = computed(() => {
234
- return form.errors.every((issue) => issue.path === void 0 || !isPathWithinScope(issue.path, arrayPathSegments.value));
460
+ /** Two-way link to the form's value at `path`. */
461
+ const model = computed({
462
+ get: () => {
463
+ return host.getValue(path.value);
464
+ },
465
+ set: (value) => {
466
+ if (!isDisposed) host.setValue(path.value, value);
467
+ }
468
+ });
469
+ const isDirty = computed(() => {
470
+ return !dequal(model.value, host.getInitialValue(path.value));
235
471
  });
236
- /** Whether the array field's own path, or any path nested under it, has been touched. */
237
472
  const isTouched = computed(() => {
238
- return [...form.touchedFields].some((touchedPath) => isPathWithinScope(parsePath(touchedPath), arrayPathSegments.value));
473
+ return host.isFieldTouched(id);
239
474
  });
240
- /** Whether the array field's own path, or any path nested under it, differs from the baseline. */
241
- const isDirty = computed(() => {
242
- return [...form.dirtyFields].some((dirtyPath) => isPathWithinScope(parsePath(dirtyPath), arrayPathSegments.value));
475
+ const errors = computed(() => {
476
+ return host.getFieldErrors(path.value);
477
+ });
478
+ const isValid = computed(() => {
479
+ return errors.value.length === 0;
243
480
  });
244
- /** Whether the array field's own path, or any path nested under it, is currently validating. */
245
481
  const isValidating = computed(() => {
246
- return [...form.validatingFields].some((validatingPath) => isPathWithinScope(parsePath(validatingPath), arrayPathSegments.value));
482
+ return host.isFieldValidating(path.value);
247
483
  });
248
- /**
249
- * Allocates a unique key for one array item.
250
- * @returns A key that never repeats in this field instance.
251
- */
252
- function createItemKey() {
253
- return `notform-array-item-${nextItemKeyId++}`;
484
+ function validate() {
485
+ if (isDisposed) return Promise.resolve({ value: void 0 });
486
+ host.cancelFieldTask(id);
487
+ return host.validateField(path.value);
254
488
  }
255
489
  /**
256
- * Pads or trims `itemKeys` from the end so it matches `targetLength`.
257
- * @param targetLength Desired key count.
490
+ * Validates in response to `change` / `input`, honouring `validateOn`, the mode and the debounce.
491
+ * @param event The event that fired.
258
492
  */
259
- function syncItemKeysToLength(targetLength) {
260
- while (itemKeys.value.length < targetLength) itemKeys.value.push(createItemKey());
261
- if (itemKeys.value.length > targetLength) itemKeys.value.length = targetLength;
493
+ function validateOnEvent(event) {
494
+ if (isDisposed) return;
495
+ if (!(config.validateOn[event] || config.validationMode === "eager" && errors.value.length > 0)) return;
496
+ if (config.debounce) {
497
+ host.scheduleFieldTask(id, config.debounce, () => {
498
+ validate();
499
+ });
500
+ return;
501
+ }
502
+ validate();
503
+ }
504
+ function onBlur() {
505
+ if (isDisposed) return;
506
+ host.touchField(id);
507
+ if (config.validateOn.blur) validate();
508
+ }
509
+ function onChange() {
510
+ validateOnEvent("change");
262
511
  }
512
+ function onInput() {
513
+ validateOnEvent("input");
514
+ }
515
+ function dispose() {
516
+ if (isDisposed) return;
517
+ if (lastKnownPath === "") lastKnownPath = options.getPath();
518
+ isDisposed = true;
519
+ host.cancelFieldTask(id);
520
+ host.untouchField(id);
521
+ }
522
+ return {
523
+ dispose,
524
+ isDisposed: () => {
525
+ return isDisposed;
526
+ },
527
+ parts: {
528
+ errors,
529
+ events: {
530
+ onBlur,
531
+ onChange,
532
+ onInput
533
+ },
534
+ id,
535
+ isDirty,
536
+ isTouched,
537
+ isValid,
538
+ isValidating,
539
+ model,
540
+ path,
541
+ validate
542
+ }
543
+ };
544
+ }
545
+ /**
546
+ * Merges the user's config over the defaults.
547
+ * @param config The config passed to `form.field()`.
548
+ * @returns A complete config (blur and change validate, input does not; eager mode).
549
+ */
550
+ function resolveFieldConfig(config) {
551
+ return {
552
+ debounce: normalizeDebounce(config?.debounce),
553
+ validateOn: {
554
+ blur: config?.validateOn?.blur ?? true,
555
+ change: config?.validateOn?.change ?? true,
556
+ input: config?.validateOn?.input ?? false
557
+ },
558
+ validationMode: config?.validationMode ?? "eager"
559
+ };
560
+ }
561
+ // src/factories/create-array-field.ts
562
+ /**
563
+ * Creates an array field: a field for the array itself, plus one stable identity
564
+ * per item and the mutations that keep values, ids and errors in step.
565
+ *
566
+ * Bookkeeping lives in one place: `ids`, a list parallel to the array.
567
+ * - Every mutation edits the array, `ids` and the form's errors together.
568
+ * - If the array is changed behind our back, `ids` is reconciled by length the next
569
+ * time it is read (new ids at the end, extra ids dropped). On form reset all ids are renewed.
570
+ * - Once the field is disposed (its item was removed) it is inert: mutations do nothing and
571
+ * `items` is empty, so a stale handle can never write into the item that took its place.
572
+ * @template TItem - Type of one array item.
573
+ * @param host The form the field belongs to.
574
+ * @param options Path source, behavior and the scope factory for nested fields.
575
+ * @returns The array field instance and its teardown.
576
+ * @remarks Internal. Use `form.arrayField(path)`, which caches the result.
577
+ */
578
+ function createArrayField(host, options) {
579
+ const base = createFieldState(host, {
580
+ config: options.config,
581
+ getPath: options.getPath,
582
+ id: createFieldId("array-field")
583
+ });
584
+ const arrayPath = base.parts.path;
585
+ /** One id per array item, same order as the array. Mutated in place; `triggerRef` announces changes. */
586
+ const ids = shallowRef([]);
587
+ /** Item fields by id, created on demand. */
588
+ const itemNodes = /* @__PURE__ */ new Map();
589
+ /** The form's reset count when `ids` was last in sync. */
590
+ let seenResetCount = host.getResetCount();
263
591
  /**
264
- * Returns the mutable array at `props.path`, creating an empty array if needed.
265
- * @returns The live array stored on the form.
592
+ * Reads the array this field points at.
593
+ * @returns The reactive array, or `undefined` if the value is missing or not an array.
266
594
  */
267
- function getOrCreateArrayValue() {
268
- const existingArray = getProperty(form.values, props.path);
269
- if (Array.isArray(existingArray)) return existingArray;
270
- const createdArray = [];
271
- setProperty(form.values, props.path, createdArray);
272
- return createdArray;
595
+ function readArray() {
596
+ const value = host.getValue(arrayPath.value);
597
+ return Array.isArray(value) ? value : void 0;
273
598
  }
274
599
  /**
275
- * Moves one element inside `target` from `fromIndex` to `toIndex`.
276
- * @template TItem Element type.
277
- * @param target Array to mutate in place.
278
- * @param fromIndex Index of the element to move.
279
- * @param toIndex Destination index, clamped to the array bounds.
600
+ * Reads the array, creating an empty one if the value is missing.
601
+ * @returns The reactive array.
602
+ * @throws {TypeError} If an array cannot be created at the path.
280
603
  */
281
- function moveArrayItem(target, fromIndex, toIndex) {
282
- if (fromIndex === toIndex || fromIndex < 0 || fromIndex >= target.length) return;
283
- const clampedToIndex = Math.max(0, Math.min(toIndex, target.length - 1));
284
- const [movedItem] = target.splice(fromIndex, 1);
285
- target.splice(clampedToIndex, 0, movedItem);
604
+ function ensureArray() {
605
+ const existing = readArray();
606
+ if (existing) return existing;
607
+ host.setValue(arrayPath.value, []);
608
+ const created = readArray();
609
+ if (!created) throw new TypeError(`[notform] Cannot create an array at "${arrayPath.value}"`);
610
+ return created;
286
611
  }
287
612
  /**
288
- * Appends `value` and a new key at the end of the array.
289
- * @param value Item to append.
613
+ * Tears down item fields whose ids left the list.
614
+ * @param removedIds The ids that were removed.
615
+ * @param isDeferred Run the teardown in a microtask. Needed when called while a computed is
616
+ * evaluating, because teardown writes reactive state (the touched set).
290
617
  */
291
- function append(value) {
292
- getOrCreateArrayValue().push(value);
293
- itemKeys.value.push(createItemKey());
618
+ function releaseItems(removedIds, isDeferred) {
619
+ for (const removedId of removedIds) {
620
+ const node = itemNodes.get(removedId);
621
+ if (!node) continue;
622
+ itemNodes.delete(removedId);
623
+ if (isDeferred) queueMicrotask(node.dispose);
624
+ else node.dispose();
625
+ }
294
626
  }
295
627
  /**
296
- * Inserts `value` at the start and remaps later item state — at any depth
297
- * underneath each item — up by one.
298
- * @param value Item to prepend.
628
+ * Brings `ids` in line with the array, then returns it.
629
+ * @returns The id list (same length as the array).
299
630
  */
300
- function prepend(value) {
301
- getOrCreateArrayValue().unshift(value);
302
- itemKeys.value.unshift(createItemKey());
303
- remapArrayFieldState(form, props.path, (previousIndex) => previousIndex + 1);
631
+ function syncIds() {
632
+ const list = ids.value;
633
+ if (base.isDisposed()) return list;
634
+ const resetCount = host.getResetCount();
635
+ if (resetCount !== seenResetCount) {
636
+ seenResetCount = resetCount;
637
+ releaseItems(list.splice(0), true);
638
+ }
639
+ const length = readArray()?.length ?? 0;
640
+ while (list.length < length) list.push(createFieldId("array-item"));
641
+ if (list.length > length) releaseItems(list.splice(length), true);
642
+ return list;
304
643
  }
305
644
  /**
306
- * Inserts `value` at `index` and remaps later item state — at any depth
307
- * underneath each item — up by one.
308
- * @param index Insertion index.
309
- * @param value Item to insert.
645
+ * Announces a finished mutation: refreshes `items` and validates per the field's config.
310
646
  */
647
+ function commit() {
648
+ triggerRef(ids);
649
+ base.parts.events.onChange();
650
+ }
311
651
  function insert(index, value) {
312
- getOrCreateArrayValue().splice(index, 0, value);
313
- itemKeys.value.splice(index, 0, createItemKey());
314
- remapArrayFieldState(form, props.path, (previousIndex) => previousIndex >= index ? previousIndex + 1 : previousIndex);
652
+ if (base.isDisposed()) return;
653
+ const list = syncIds();
654
+ const array = ensureArray();
655
+ const at = clampIndex(index, list.length);
656
+ list.splice(at, 0, createFieldId("array-item"));
657
+ array.splice(at, 0, cloneValue(value));
658
+ host.remapArrayErrors(arrayPath.value, createInsertMapper(at));
659
+ commit();
660
+ }
661
+ function append(value) {
662
+ insert(syncIds().length, value);
663
+ }
664
+ function prepend(value) {
665
+ insert(0, value);
315
666
  }
316
- /**
317
- * Removes the item at `index` and remaps later item state — at any depth
318
- * underneath each item — down by one. State nested under the removed
319
- * item's own path, at any depth, is discarded along with it.
320
- * @param index Index to remove.
321
- */
322
667
  function remove(index) {
323
- getOrCreateArrayValue().splice(index, 1);
324
- itemKeys.value.splice(index, 1);
325
- remapArrayFieldState(form, props.path, (previousIndex) => {
326
- if (previousIndex === index) return;
327
- return previousIndex > index ? previousIndex - 1 : previousIndex;
328
- });
668
+ const list = syncIds();
669
+ if (base.isDisposed() || !isIndexInRange(index, list.length)) return;
670
+ releaseItems(list.splice(index, 1), false);
671
+ ensureArray().splice(index, 1);
672
+ host.remapArrayErrors(arrayPath.value, createRemoveMapper(index));
673
+ commit();
674
+ }
675
+ function move(from, to) {
676
+ const list = syncIds();
677
+ if (from === to || base.isDisposed() || !isIndexInRange(from, list.length) || !isIndexInRange(to, list.length)) return;
678
+ moveListItem(list, from, to);
679
+ moveListItem(ensureArray(), from, to);
680
+ host.remapArrayErrors(arrayPath.value, createMoveMapper(from, to));
681
+ commit();
682
+ }
683
+ function swap(indexA, indexB) {
684
+ const list = syncIds();
685
+ if (indexA === indexB || base.isDisposed() || !isIndexInRange(indexA, list.length) || !isIndexInRange(indexB, list.length)) return;
686
+ swapListItems(list, indexA, indexB);
687
+ swapListItems(ensureArray(), indexA, indexB);
688
+ host.remapArrayErrors(arrayPath.value, createSwapMapper(indexA, indexB));
689
+ commit();
329
690
  }
330
- /**
331
- * Replaces the value at `index` without changing its key.
332
- * @param index Index to update.
333
- * @param value New item value.
334
- */
335
691
  function update(index, value) {
336
- getOrCreateArrayValue()[index] = value;
692
+ if (base.isDisposed() || !isIndexInRange(index, syncIds().length)) return;
693
+ ensureArray()[index] = cloneValue(value);
694
+ host.remapArrayErrors(arrayPath.value, createDiscardMapper(index));
695
+ commit();
337
696
  }
338
697
  /**
339
- * Swaps two items, moving keys and form state — at any depth underneath
340
- * each item — with them.
341
- * @param indexA First index.
342
- * @param indexB Second index.
698
+ * Creates the field of one array item.
699
+ * @param id The item's id.
700
+ * @returns The item instance and its teardown.
343
701
  */
344
- function swap(indexA, indexB) {
345
- const array = getOrCreateArrayValue();
346
- [array[indexA], array[indexB]] = [array[indexB], array[indexA]];
347
- [itemKeys.value[indexA], itemKeys.value[indexB]] = [itemKeys.value[indexB], itemKeys.value[indexA]];
348
- remapArrayFieldState(form, props.path, (previousIndex) => {
349
- if (previousIndex === indexA) return indexB;
350
- if (previousIndex === indexB) return indexA;
351
- return previousIndex;
702
+ function createItemNode(id) {
703
+ let lastIndex = ids.value.indexOf(id);
704
+ /** Position of the item. Keeps its last value once the item is gone, so its path never turns into `-1`. */
705
+ const index = computed(() => {
706
+ const currentIndex = syncIds().indexOf(id);
707
+ if (currentIndex !== -1) lastIndex = currentIndex;
708
+ return lastIndex;
709
+ });
710
+ const getItemPath = () => {
711
+ return joinPath(arrayPath.value, index.value);
712
+ };
713
+ const scope = options.createScope(getItemPath);
714
+ const state = createFieldState(host, {
715
+ config: options.config,
716
+ getPath: getItemPath,
717
+ id
352
718
  });
719
+ return {
720
+ dispose: () => {
721
+ scope.dispose();
722
+ state.dispose();
723
+ },
724
+ instance: reactive({
725
+ ...state.parts,
726
+ arrayField: scope.arrayField,
727
+ field: scope.field,
728
+ index
729
+ })
730
+ };
353
731
  }
354
732
  /**
355
- * Moves one item to `toIndex`, shifting neighbors and remapping form
356
- * state — at any depth underneath each item.
357
- * @param fromIndex Current index.
358
- * @param toIndex Destination index.
733
+ * Gets or creates the field of the item with this id.
734
+ * @param id The item's id.
735
+ * @returns The cached item instance.
359
736
  */
360
- function move(fromIndex, toIndex) {
361
- moveArrayItem(getOrCreateArrayValue(), fromIndex, toIndex);
362
- moveArrayItem(itemKeys.value, fromIndex, toIndex);
363
- remapArrayFieldState(form, props.path, (previousIndex) => {
364
- if (previousIndex === fromIndex) return toIndex;
365
- if (fromIndex < toIndex) return previousIndex > fromIndex && previousIndex <= toIndex ? previousIndex - 1 : previousIndex;
366
- return previousIndex >= toIndex && previousIndex < fromIndex ? previousIndex + 1 : previousIndex;
367
- });
737
+ function getItem(id) {
738
+ let node = itemNodes.get(id);
739
+ if (!node) {
740
+ node = createItemNode(id);
741
+ itemNodes.set(id, node);
742
+ }
743
+ return node.instance;
368
744
  }
369
- watch(() => arrayValue.value.length, (arrayLength) => {
370
- if (itemKeys.value.length === arrayLength) return;
371
- syncItemKeysToLength(arrayLength);
372
- }, { immediate: true });
373
- return reactive({
374
- append,
375
- errors,
376
- insert,
377
- isDirty,
378
- isTouched,
379
- isValid,
380
- isValidating,
381
- items,
382
- move,
383
- path: computed(() => props.path),
384
- prepend,
385
- remove,
386
- swap,
387
- update
745
+ const items = computed(() => {
746
+ return base.isDisposed() ? [] : syncIds().map((id) => {
747
+ return getItem(id);
748
+ });
388
749
  });
750
+ function dispose() {
751
+ for (const node of itemNodes.values()) node.dispose();
752
+ itemNodes.clear();
753
+ base.dispose();
754
+ }
755
+ return {
756
+ dispose,
757
+ instance: reactive({
758
+ ...base.parts,
759
+ append,
760
+ insert,
761
+ items,
762
+ move,
763
+ prepend,
764
+ remove,
765
+ swap,
766
+ update
767
+ })
768
+ };
389
769
  }
390
- //#endregion
391
- //#region src/components/not-array-field.vue
392
- var not_array_field_default = /* @__PURE__ */ defineComponent({
393
- __name: "not-array-field",
394
- props: {
395
- path: {
396
- type: [String, Object],
397
- required: true
398
- },
399
- itemSchema: {
400
- type: null,
401
- required: false
402
- },
403
- form: {
404
- type: Object,
405
- required: false
770
+ /**
771
+ * Deep-copies a value on its way in, so two items never share one object.
772
+ * @param value The value to copy.
773
+ * @returns The copy.
774
+ */
775
+ function cloneValue(value) {
776
+ return klona(toRaw(value));
777
+ }
778
+ // src/factories/create-field-scope.ts
779
+ /**
780
+ * Creates a scope: the place that creates fields under a base path and keeps
781
+ * one instance per relative path.
782
+ *
783
+ * Caching is what gives fields a stable identity. A template may call
784
+ * `form.field('name')` on every render and always gets the same field, so its
785
+ * id, touched state and pending validation survive re-renders.
786
+ * @param host The form the fields belong to.
787
+ * @param getBasePath Returns the base path. The form root uses `() => ''`; an array item
788
+ * uses its own current path, so nested fields follow the item when it moves.
789
+ * @returns The scope. After `dispose()`, fields it still hands out are inert.
790
+ */
791
+ function createFieldScope(host, getBasePath) {
792
+ const nodes = /* @__PURE__ */ new Map();
793
+ let isDisposed = false;
794
+ function field(path, config) {
795
+ const key = `field:${path}`;
796
+ let node = nodes.get(key);
797
+ if (!node) {
798
+ node = createField(host, {
799
+ config,
800
+ getPath: () => {
801
+ return joinPath(getBasePath(), path);
802
+ }
803
+ });
804
+ nodes.set(key, node);
805
+ if (isDisposed) node.dispose();
406
806
  }
407
- },
408
- setup(__props) {
409
- const arrayField = useNotArrayField(__props);
410
- return (_ctx, _cache) => {
411
- return renderSlot(_ctx.$slots, "default", normalizeProps(guardReactiveProps(unref(arrayField))));
412
- };
807
+ return node.instance;
413
808
  }
414
- });
415
- //#endregion
416
- //#region src/composables/use-not-field.ts
417
- /**
418
- * Field state, validation, and mutation helpers for `<NotField>`.
419
- * @template TSchema The form schema.
420
- * @internal
421
- * @param props Path and optional form and validation triggers.
422
- * @returns Slot state for `<NotField>`.
423
- */
424
- function useNotField(props) {
425
- const form = useNotFormInstance(props.form);
426
- const errors = computed(() => form.getFieldErrors(props.path));
427
- const isValid = computed(() => errors.value.length === 0);
428
- const isTouched = computed(() => form.touchedFields.has(props.path));
429
- const isDirty = computed(() => form.dirtyFields.has(props.path));
430
- const isValidating = computed(() => form.validatingFields.has(props.path));
431
- const validateOn = computed(() => ({
432
- onBlur: true,
433
- onChange: true,
434
- ...props.validateOn
435
- }));
436
- let debounceTimer;
437
- /** Clears a pending debounce timer. */
438
- function clearDebounce() {
439
- if (debounceTimer === void 0) return;
440
- clearTimeout(debounceTimer);
441
- debounceTimer = void 0;
442
- }
443
- /** Runs field validation immediately, or after `debounce` ms. */
444
- function scheduleValidation() {
445
- if (!props.debounce) {
446
- form.validateField(props.path);
447
- return;
809
+ function arrayField(path, config) {
810
+ const key = `array:${path}`;
811
+ let node = nodes.get(key);
812
+ if (!node) {
813
+ node = createArrayField(host, {
814
+ config,
815
+ createScope: (getItemPath) => {
816
+ return createFieldScope(host, getItemPath);
817
+ },
818
+ getPath: () => {
819
+ return joinPath(getBasePath(), path);
820
+ }
821
+ });
822
+ nodes.set(key, node);
823
+ if (isDisposed) node.dispose();
448
824
  }
449
- clearDebounce();
450
- debounceTimer = setTimeout(() => {
451
- form.validateField(props.path);
452
- }, props.debounce);
453
- }
454
- /** Marks touched and validates on blur when enabled. */
455
- function onBlur() {
456
- clearDebounce();
457
- form.markFieldAsTouched(props.path);
458
- if (validateOn.value?.onBlur) form.validateField(props.path);
825
+ return node.instance;
459
826
  }
460
- /** Syncs dirty and, in eager mode, revalidates on input while invalid. */
461
- function onInput() {
462
- form.syncDirtyState(props.path);
463
- if (!validateOn.value?.onInput) return;
464
- if (props.validationMode === "eager" && !isValid.value) scheduleValidation();
827
+ function dispose() {
828
+ isDisposed = true;
829
+ for (const node of nodes.values()) node.dispose();
830
+ nodes.clear();
465
831
  }
466
- /** Syncs dirty and, in eager mode, revalidates on change while invalid. */
467
- function onChange() {
468
- form.syncDirtyState(props.path);
469
- if (!validateOn.value?.onChange) return;
470
- if (props.validationMode === "eager" && !isValid.value) scheduleValidation();
832
+ return {
833
+ arrayField,
834
+ dispose,
835
+ field
836
+ };
837
+ }
838
+ // src/factories/create-validation-run-registry.ts
839
+ /**
840
+ * Creates a tracker for validations that are currently running.
841
+ *
842
+ * Validation is async, so a second one can start before the first finishes.
843
+ * The registry marks the older run stale, so its result is dropped instead of
844
+ * overwriting fresher data.
845
+ * @returns Functions to start a run, finish a run, and invalidate every run.
846
+ * @remarks One registry per form.
847
+ * @example
848
+ * const run = runs.startRun('field', 'email')
849
+ * // ... await validation ...
850
+ * if (!run.isStale) {
851
+ * applyResult()
852
+ * }
853
+ * runs.finishRun(run)
854
+ */
855
+ function createValidationRunRegistry() {
856
+ /** Id of the most recently started run. Only ever goes up. */
857
+ let latestRunId = 0;
858
+ /** Runs that started but have not finished, keyed by run id. */
859
+ const activeRuns = reactive(/* @__PURE__ */ new Map());
860
+ /**
861
+ * Registers a run and marks the older runs it replaces as stale.
862
+ *
863
+ * A run replaces another when its result would overwrite it (see `doesScopeCover`).
864
+ * A running submit is never replaced by a non-submit run: a field check or a
865
+ * manual `validate()` must not cancel a submit in progress.
866
+ * @param origin What started the run.
867
+ * @param path Dot-notation field path. Leave out for a whole-form run.
868
+ * @returns The new run. Check `run.isStale` after awaiting.
869
+ */
870
+ function startRun(origin, path) {
871
+ const newRun = {
872
+ id: ++latestRunId,
873
+ isStale: false,
874
+ origin,
875
+ scope: getValidationScope(path)
876
+ };
877
+ for (const [activeRunId, activeRun] of activeRuns) {
878
+ if (origin !== "submit" && activeRun.origin === "submit" || !doesScopeCover(newRun.scope, activeRun.scope)) continue;
879
+ activeRun.isStale = true;
880
+ activeRuns.delete(activeRunId);
881
+ }
882
+ activeRuns.set(newRun.id, newRun);
883
+ return newRun;
471
884
  }
472
885
  /**
473
- * Validates on mount when `validateOn.onMount` is set. This is the only
474
- * trigger not exposed through `events` below, since there's no DOM event
475
- * to bind — it runs on its own via the `onMounted` hook further down.
886
+ * Removes a run from the active list, whether it succeeded, failed or went stale. Safe to call twice.
887
+ * @param run The run returned by `startRun`.
476
888
  */
477
- function onMount() {
478
- if (validateOn.value?.onMount) form.validateField(props.path);
889
+ function finishRun(run) {
890
+ activeRuns.delete(run.id);
891
+ }
892
+ /** Marks every active run stale and empties the list (form reset, value change, schema change). */
893
+ function markAllRunsStale() {
894
+ for (const run of activeRuns.values()) run.isStale = true;
895
+ activeRuns.clear();
479
896
  }
480
- const events = {
481
- onBlur,
482
- onChange,
483
- onInput
897
+ return {
898
+ activeRuns: readonly(activeRuns),
899
+ finishRun,
900
+ markAllRunsStale,
901
+ startRun
484
902
  };
485
- onMounted(async () => {
486
- await nextTick();
487
- onMount();
903
+ }
904
+ // src/factories/create-form.ts
905
+ /**
906
+ * Builds a complete reactive form: values, errors, validation, submit handling,
907
+ * and the registry that gives every field a stable identity.
908
+ *
909
+ * Single source of truth:
910
+ * - values live in `values`; fields only point at them by path;
911
+ * - errors live in `errors` as the schema reports them (by path); array mutations re-point them;
912
+ * - touched state lives in `touchedFields`, keyed by field id, so it survives reordering;
913
+ * - pending debounced validations live in `fieldTasks`, keyed by field id.
914
+ * @template TSchema - The schema describing the form's data (must be an object schema).
915
+ * @param config Schema, starting values/errors, and the submit handler.
916
+ * @returns The full reactive form.
917
+ * @remarks Internal building block. Prefer the `useForm` composable.
918
+ */
919
+ function createForm(config) {
920
+ const initialValues = shallowRef(klona(toRaw(config.initialValues ?? {})));
921
+ const initialErrors = shallowRef(klona(toRaw(config.initialErrors ?? [])));
922
+ /** Live form data (a separate copy of the initial values). */
923
+ const values = reactive(klona(initialValues.value));
924
+ /** Live validation errors (a separate copy of the initial errors). */
925
+ const errors = reactive(klona(initialErrors.value));
926
+ /** Ids of the fields that were blurred. */
927
+ const touchedFields = shallowReactive(/* @__PURE__ */ new Set());
928
+ /** Debounced validations waiting to fire, by field id. */
929
+ const fieldTasks = /* @__PURE__ */ new Map();
930
+ const isSubmitting = ref(false);
931
+ const submitCount = ref(0);
932
+ const resetCount = ref(0);
933
+ const isSubmitted = computed(() => {
934
+ return submitCount.value > 0;
488
935
  });
489
- onUnmounted(() => {
490
- clearDebounce();
936
+ const isTouched = computed(() => {
937
+ return isSubmitted.value || touchedFields.size > 0;
491
938
  });
492
- return reactive({
493
- errors,
494
- events,
495
- isDirty,
496
- isTouched,
497
- isValid,
498
- isValidating,
499
- path: computed(() => props.path)
939
+ const isDirty = computed(() => {
940
+ return !dequal(values, initialValues.value);
500
941
  });
501
- }
502
- //#endregion
503
- //#region src/components/not-field.vue
504
- var not_field_default = /* @__PURE__ */ defineComponent({
505
- __name: "not-field",
506
- props: {
507
- path: {
508
- type: [String, Object],
509
- required: true
510
- },
511
- form: {
512
- type: Object,
513
- required: false
514
- },
515
- validateOn: {
516
- type: Object,
517
- required: false,
518
- default: void 0
519
- },
520
- validationMode: {
521
- type: String,
522
- required: false,
523
- default: "eager"
524
- },
525
- debounce: {
526
- type: Number,
527
- required: false,
528
- default: void 0
529
- }
530
- },
531
- setup(__props) {
532
- const field = useNotField(__props);
533
- return (_ctx, _cache) => {
534
- return renderSlot(_ctx.$slots, "default", normalizeProps(guardReactiveProps(unref(field))));
535
- };
942
+ const isValid = computed(() => {
943
+ return errors.length === 0;
944
+ });
945
+ function touchField(id) {
946
+ touchedFields.add(id);
536
947
  }
537
- });
538
- //#endregion
539
- //#region src/components/not-form.vue
540
- var not_form_default = /* @__PURE__ */ defineComponent({
541
- inheritAttrs: false,
542
- __name: "not-form",
543
- props: { form: {
544
- type: Object,
545
- required: true
546
- } },
547
- setup(__props) {
548
- provideNotFormInstance(__props.form);
549
- return (_ctx, _cache) => {
550
- return openBlock(), createElementBlock(Fragment, null, [createCommentVNode("\n Reset is always prevented here, regardless of what (if anything) the\n consumer binds to @reset, since a native reset would otherwise clear\n every input before `values` has a chance to react to it.\n\n Submit is NOT prevented here. `form.submit()` calls `event.preventDefault()`\n itself when it runs, which only happens if the consumer binds @submit to it\n (e.g. `@submit=\"form.submit\"`) — see the NotForm docs for why this matters.\n "), createElementVNode("form", mergeProps(_ctx.$attrs, { onReset: _cache[0] || (_cache[0] = withModifiers(() => {}, ["prevent"])) }), [renderSlot(_ctx.$slots, "default")], 16)], 2112);
551
- };
948
+ function untouchField(id) {
949
+ touchedFields.delete(id);
552
950
  }
553
- });
554
- //#endregion
555
- //#region src/components/not-message.vue
556
- var not_message_default = /* @__PURE__ */ defineComponent({
557
- inheritAttrs: false,
558
- __name: "not-message",
559
- props: {
560
- as: {
561
- type: null,
562
- required: false,
563
- default: "span"
564
- },
565
- path: {
566
- type: [String, Object],
567
- required: true
568
- },
569
- form: {
570
- type: Object,
571
- required: false
572
- }
573
- },
574
- setup(__props) {
575
- const props = __props;
576
- const form = useNotFormInstance(props.form);
577
- const message = computed(() => form.getFieldErrors(props.path)[0]?.message);
578
- return (_ctx, _cache) => {
579
- return message.value ? (openBlock(), createBlock(resolveDynamicComponent(__props.as), normalizeProps(mergeProps({ key: 0 }, _ctx.$attrs)), {
580
- default: withCtx(() => [renderSlot(_ctx.$slots, "default", { message: message.value }, () => [createTextVNode(toDisplayString(message.value), 1)])]),
581
- _: 3
582
- }, 16)) : createCommentVNode("v-if", true);
583
- };
951
+ function isFieldTouched(id) {
952
+ return isSubmitted.value || touchedFields.has(id);
584
953
  }
585
- });
586
- //#endregion
587
- //#region src/utils/issues.ts
588
- /**
589
- * Whether two issue paths point at the same field after segment normalization.
590
- * @internal
591
- * @param issuePathA First path.
592
- * @param issuePathB Second path.
593
- * @returns `true` when both paths are defined, same length, and equal segment-wise.
594
- */
595
- function areIssuePathsEqual(issuePathA, issuePathB) {
596
- if (!issuePathA || !issuePathB) return false;
597
- if (issuePathA.length !== issuePathB.length) return false;
598
- return issuePathA.every((segment, segmentIndex) => areSegmentsEqual(segment, issuePathB[segmentIndex]));
599
- }
600
- //#endregion
601
- //#region src/factories/create-not-form-instance.ts
602
- /**
603
- * Builds the full reactive form instance used by `useNotForm` and field components.
604
- * @template TSchema The form schema.
605
- * @internal
606
- * @param config Schema, initial values/errors, and submit handler.
607
- * @returns Assembled {@linkcode NotFormInstance}.
608
- */
609
- function createNotFormInstance(config) {
610
- /** Baseline values for reset. */
611
- const initialValues = klona(config.initialValues ?? {});
612
- /** Live field values. */
613
- const values = reactive(klona(initialValues));
614
- /** Baseline issues for reset. */
615
- const initialErrors = klona(config.initialErrors ?? []);
616
- /** Current validation issues. */
617
- const errors = reactive([...initialErrors]);
618
- /** Paths the user has interacted with. */
619
- const touchedFields = reactive(/* @__PURE__ */ new Set());
620
- const isTouched = computed(() => touchedFields.size > 0);
621
- /** Paths whose value differs from the baseline. */
622
- const dirtyFields = reactive(/* @__PURE__ */ new Set());
623
- const isDirty = computed(() => dirtyFields.size > 0);
624
- /** Paths with an in-flight validation. */
625
- const validatingFields = reactive(/* @__PURE__ */ new Set());
626
- const isValid = computed(() => errors.length === 0);
627
- const isValidating = computed(() => validatingFields.size > 0);
628
- const isSubmitting = ref(false);
954
+ function cancelFieldTask(id) {
955
+ const timer = fieldTasks.get(id);
956
+ if (timer === void 0) return;
957
+ clearTimeout(timer);
958
+ fieldTasks.delete(id);
959
+ }
960
+ function cancelAllFieldTasks() {
961
+ for (const timer of fieldTasks.values()) clearTimeout(timer);
962
+ fieldTasks.clear();
963
+ }
964
+ function scheduleFieldTask(id, delay, task) {
965
+ cancelFieldTask(id);
966
+ fieldTasks.set(id, setTimeout(() => {
967
+ fieldTasks.delete(id);
968
+ task();
969
+ }, delay));
970
+ }
971
+ /** Tracks running validations so outdated results can be discarded. */
972
+ const validationRuns = createValidationRunRegistry();
629
973
  /**
630
- * Bumped by validate, submit, and reset. Older in-flight runs are discarded:
631
- * each still resolves with its own result, but only the run matching the
632
- * current generation when it finishes gets to write to `errors`.
974
+ * Increases every time a submit starts. A submit loop keeps going only while its own
975
+ * attempt id is still the latest, so bumping this elsewhere cancels the submit in progress.
633
976
  */
634
- let generation = 0;
635
- /** Latest per-field validation cycle id, used to drop stale field results. */
636
- const fieldValidationCycleMap = /* @__PURE__ */ new Map();
637
- /** Active validation count per path so overlapping runs do not clear too early. */
638
- const validatingFieldCounts = /* @__PURE__ */ new Map();
977
+ let latestSubmitAttemptId = 0;
978
+ const isValidating = computed(() => {
979
+ return validationRuns.activeRuns.size > 0;
980
+ });
639
981
  /**
640
- * Runs the current schema against `values`. Resolves `config.schema`
641
- * first via `toValue`, so a ref or a getter function works the same as
642
- * passing the schema directly.
643
- * @returns Standard Schema result.
982
+ * Validates a snapshot of the current values against the schema.
983
+ * @returns The schema result (`{ value }` or `{ issues }`), sync or async.
644
984
  */
645
- function executeSchemaValidation() {
646
- return toValue(config.schema)["~standard"].validate(values);
985
+ function validateAgainstSchema() {
986
+ const schema = toValue(config.schema);
987
+ const valuesSnapshot = klona(toRaw(values));
988
+ return schema["~standard"].validate(valuesSnapshot);
647
989
  }
648
990
  /**
649
- * Sets a field value and syncs its dirty flag. Does not validate.
650
- * @template TPath Field path.
651
- * @param path Dot path.
652
- * @param value Value to assign.
991
+ * Runs one validation and reports whether it was replaced meanwhile.
992
+ * Never throws: a crashing schema becomes a normal issue.
993
+ * @param origin What started this validation.
994
+ * @param path Dot-notation field path. Leave out for a whole-form run.
995
+ * @returns The schema result and whether it went stale.
653
996
  */
654
- function setValue(path, value) {
655
- setProperty(values, path, value);
656
- syncDirtyState(path);
997
+ async function runValidation(origin, path) {
998
+ const run = validationRuns.startRun(origin, path);
999
+ try {
1000
+ let result;
1001
+ try {
1002
+ result = await validateAgainstSchema();
1003
+ } catch (error) {
1004
+ result = { issues: [createValidationIssueFromError(error, path)] };
1005
+ }
1006
+ return {
1007
+ isStale: run.isStale,
1008
+ result
1009
+ };
1010
+ } finally {
1011
+ validationRuns.finishRun(run);
1012
+ }
657
1013
  }
658
- /**
659
- * Upserts an issue at the same path, or appends it.
660
- * @param error Issue to set.
661
- */
662
- function setError(error) {
663
- const existingIndex = errors.findIndex((existing) => areIssuePathsEqual(existing.path, error.path));
664
- if (existingIndex === -1) errors.push(error);
665
- else errors[existingIndex] = error;
1014
+ watch(() => {
1015
+ return toValue(config.schema);
1016
+ }, () => {
1017
+ validationRuns.markAllRunsStale();
1018
+ }, { flush: "sync" });
1019
+ watch(values, () => {
1020
+ validationRuns.markAllRunsStale();
1021
+ }, {
1022
+ deep: true,
1023
+ flush: "sync"
1024
+ });
1025
+ function getValue(path) {
1026
+ return getValueAtPath(values, path);
1027
+ }
1028
+ function setValue(path, value) {
1029
+ setValueAtPath(values, path, value);
666
1030
  }
667
- /**
668
- * Replaces the entire error list.
669
- * @internal
670
- * @param nextErrors Replacement issues.
671
- */
672
1031
  function replaceErrors(nextErrors) {
673
1032
  errors.splice(0, errors.length, ...nextErrors);
674
1033
  }
675
- /**
676
- * Removes every issue.
677
- * @internal
678
- */
679
1034
  function clearErrors() {
680
1035
  errors.length = 0;
681
1036
  }
682
- /**
683
- * Issues whose path exactly equals `path` — see the same note on
684
- * {@linkcode NotFormInstance.getFieldErrors}.
685
- * @param path Dot path.
686
- * @returns Matching issues.
687
- */
688
1037
  function getFieldErrors(path) {
689
- const targetPath = parsePath(path);
690
- return errors.filter((error) => areIssuePathsEqual(error.path, targetPath));
691
- }
692
- /**
693
- * Forces a field dirty.
694
- * @internal
695
- * @param path Dot path.
696
- */
697
- function markFieldAsDirty(path) {
698
- dirtyFields.add(path);
699
- }
700
- /**
701
- * Forces every current leaf field dirty. Only paths present in `values`
702
- * right now are affected — see the note on
703
- * {@linkcode NotFormInstance.markAllFieldsAsTouched}.
704
- * @internal
705
- */
706
- function markAllFieldsAsDirty() {
707
- for (const path of deepKeys(values)) dirtyFields.add(path);
708
- }
709
- /**
710
- * Clears dirty state for one field.
711
- * @internal
712
- * @param path Dot path.
713
- */
714
- function unmarkFieldAsDirty(path) {
715
- dirtyFields.delete(path);
716
- }
717
- /**
718
- * Clears dirty state for all fields.
719
- * @internal
720
- */
721
- function unmarkAllFieldsAsDirty() {
722
- dirtyFields.clear();
723
- }
724
- /**
725
- * Sets dirty from a deep compare against the baseline.
726
- * @internal
727
- * @param path Dot path.
728
- */
729
- function syncDirtyState(path) {
730
- const currentValue = getProperty(values, path);
731
- const baselineValue = getProperty(initialValues, path);
732
- if (dequal(currentValue, baselineValue)) unmarkFieldAsDirty(path);
733
- else markFieldAsDirty(path);
734
- }
735
- /**
736
- * Recalculates dirty state for every current leaf path.
737
- * @internal
738
- */
739
- function syncAllDirtyStates() {
740
- for (const path of deepKeys(values)) syncDirtyState(path);
741
- }
742
- /**
743
- * Marks a field as touched.
744
- * @internal
745
- * @param path Dot path.
746
- */
747
- function markFieldAsTouched(path) {
748
- touchedFields.add(path);
749
- }
750
- /**
751
- * Marks every current leaf field as touched. Only paths present in
752
- * `values` right now are affected — see the note on
753
- * {@linkcode NotFormInstance.markAllFieldsAsTouched}.
754
- * @internal
755
- */
756
- function markAllFieldsAsTouched() {
757
- for (const path of deepKeys(values)) touchedFields.add(path);
1038
+ const targetPath = splitPath(path);
1039
+ return errors.filter((error) => {
1040
+ return isSamePath(error.path, targetPath);
1041
+ });
758
1042
  }
759
1043
  /**
760
- * Clears touched state for one field.
761
- * @internal
762
- * @param path Dot path.
1044
+ * Replaces the errors sitting exactly at a path. Leaves the other errors alone.
1045
+ * @param segments The path whose errors are replaced.
1046
+ * @param nextIssues The new errors for that path.
763
1047
  */
764
- function unmarkFieldAsTouched(path) {
765
- touchedFields.delete(path);
1048
+ function replaceErrorsAt(segments, nextIssues) {
1049
+ const keptErrors = errors.filter((error) => {
1050
+ return !isSamePath(error.path, segments);
1051
+ });
1052
+ if (keptErrors.length === errors.length && nextIssues.length === 0) return;
1053
+ replaceErrors([...keptErrors, ...nextIssues]);
766
1054
  }
767
- /**
768
- * Clears touched state for all fields.
769
- * @internal
770
- */
771
- function unmarkAllFieldsAsTouched() {
772
- touchedFields.clear();
1055
+ function setError(error) {
1056
+ if (error.path) replaceErrorsAt(error.path, [error]);
1057
+ else errors.push(error);
773
1058
  }
774
1059
  /**
775
- * Increments the in-flight validation count for each path.
776
- * @internal
777
- * @param paths Field paths.
1060
+ * Re-points the errors inside an array after it was mutated, so each error stays with its item.
1061
+ * @param arrayPath Path of the mutated array.
1062
+ * @param mapIndex Translates an item's old index to its new one (`undefined` drops the error).
778
1063
  */
779
- function markFieldsAsValidating(paths) {
780
- for (const path of paths) {
781
- validatingFieldCounts.set(path, (validatingFieldCounts.get(path) ?? 0) + 1);
782
- validatingFields.add(path);
1064
+ function remapArrayErrors(arrayPath, mapIndex) {
1065
+ const arraySegments = splitPath(arrayPath);
1066
+ const remappedErrors = [];
1067
+ let hasChanged = false;
1068
+ for (const error of errors) {
1069
+ const remapped = remapIssueIndex(error, arraySegments, mapIndex);
1070
+ if (remapped !== error) hasChanged = true;
1071
+ if (remapped) remappedErrors.push(remapped);
783
1072
  }
1073
+ if (hasChanged) replaceErrors(remappedErrors);
1074
+ }
1075
+ function isFieldValidating(path) {
1076
+ const fieldSegments = splitPath(path);
1077
+ for (const activeRun of validationRuns.activeRuns.values()) if (isFieldAffectedByScope(activeRun.scope, fieldSegments)) return true;
1078
+ return false;
784
1079
  }
785
1080
  /**
786
- * Decrements in-flight counts and clears a path when the count hits zero.
787
- * @internal
788
- * @param paths Field paths.
1081
+ * Validates a single field and updates only that field's errors.
1082
+ * The whole form is checked against the schema, but only issues sitting exactly at `path` are kept.
1083
+ * @param path Dot-notation path to the field.
1084
+ * @returns `{ issues }` if the field is invalid, otherwise `{ value }` with its current input value.
1085
+ * A result made obsolete by a newer run is returned, but the errors are left untouched.
789
1086
  */
790
- function unmarkFieldsAsValidating(paths) {
791
- for (const path of paths) {
792
- const nextCount = (validatingFieldCounts.get(path) ?? 0) - 1;
793
- if (nextCount <= 0) {
794
- validatingFieldCounts.delete(path);
795
- validatingFields.delete(path);
796
- } else validatingFieldCounts.set(path, nextCount);
797
- }
1087
+ async function validateField(path) {
1088
+ const { isStale, result } = await runValidation("field", path);
1089
+ const fieldSegments = splitPath(path);
1090
+ const fieldIssues = (result.issues ?? []).filter((issue) => {
1091
+ return isSamePath(issue.path, fieldSegments);
1092
+ });
1093
+ if (!isStale) replaceErrorsAt(fieldSegments, fieldIssues);
1094
+ return fieldIssues.length > 0 ? { issues: fieldIssues } : { value: getValue(path) };
798
1095
  }
799
1096
  /**
800
- * Validates the whole form, replacing all errors. Bumps `generation`; see
801
- * {@linkcode NotFormInstance.validate} for the concurrency guarantee this gives.
802
- * @returns Standard Schema result.
1097
+ * Validates the whole form and replaces all errors with the outcome.
1098
+ * @returns The schema result. If a newer validation replaced this one, errors are left untouched.
803
1099
  */
804
1100
  async function validate() {
805
- const cycle = ++generation;
806
- const paths = [...deepKeys(values)];
807
- markFieldsAsValidating(paths);
808
- try {
809
- const result = await executeSchemaValidation();
810
- if (cycle !== generation) return result;
811
- if (result.issues) {
812
- const issues = [...result.issues];
813
- replaceErrors(issues);
814
- return { issues };
815
- }
816
- clearErrors();
817
- return { value: result.value };
818
- } finally {
819
- unmarkFieldsAsValidating(paths);
820
- }
1101
+ const { isStale, result } = await runValidation("form");
1102
+ if (!isStale) replaceErrors(result.issues ? [...result.issues] : []);
1103
+ return result;
821
1104
  }
822
1105
  /**
823
- * Validates the form and writes issues only for `path`. `path` can be any
824
- * granularity — see {@linkcode NotFormInstance.validateField}.
1106
+ * Validates the whole form and, if valid, calls `config.onSubmit` with the parsed data.
825
1107
  *
826
- * Dropped if a newer call for the same path or a newer whole-form generation starts.
827
- * @param path Dot path.
828
- * @returns Standard Schema result.
829
- */
830
- async function validateField(path) {
831
- const cycle = (fieldValidationCycleMap.get(path) ?? 0) + 1;
832
- fieldValidationCycleMap.set(path, cycle);
833
- const startGeneration = generation;
834
- markFieldsAsValidating([path]);
835
- try {
836
- const result = await executeSchemaValidation();
837
- if (generation !== startGeneration || fieldValidationCycleMap.get(path) !== cycle) return result;
838
- const targetPath = parsePath(path);
839
- for (let errorIndex = errors.length - 1; errorIndex >= 0; errorIndex--) if (areIssuePathsEqual(errors[errorIndex].path, targetPath)) errors.splice(errorIndex, 1);
840
- const fieldIssues = (result.issues ?? []).filter((issue) => areIssuePathsEqual(issue.path, targetPath));
841
- errors.push(...fieldIssues);
842
- if (fieldIssues.length > 0) return { issues: fieldIssues };
843
- return { value: getProperty(values, path) };
844
- } finally {
845
- unmarkFieldsAsValidating([path]);
846
- }
847
- }
848
- /**
849
- * Touches all fields, validates, then runs `onSubmit` when valid.
850
- * @param event Optional submit event. `event.preventDefault()` is called
851
- * immediately if `event` is given, before checking whether a submission
852
- * is already in flight or whether validation passes.
1108
+ * - Ignored while a submit is already running.
1109
+ * - If validation is replaced mid-flight (the user kept typing), it is simply run again.
1110
+ * - On failure, errors are shown and `onSubmit` is not called.
1111
+ * @param event Browser submit event; its default page reload is prevented.
1112
+ * @throws Whatever `config.onSubmit` throws (after `isSubmitting` has been reset).
853
1113
  */
854
1114
  async function submit(event) {
855
1115
  event?.preventDefault();
856
1116
  if (isSubmitting.value) return;
857
- markAllFieldsAsTouched();
858
- syncAllDirtyStates();
859
- const cycle = ++generation;
860
- const paths = [...deepKeys(values)];
861
- markFieldsAsValidating(paths);
862
1117
  isSubmitting.value = true;
1118
+ submitCount.value++;
1119
+ const thisSubmitAttemptId = ++latestSubmitAttemptId;
863
1120
  try {
864
- const result = await executeSchemaValidation();
865
- if (cycle !== generation) return;
866
- if (result.issues) {
867
- replaceErrors([...result.issues]);
1121
+ while (thisSubmitAttemptId === latestSubmitAttemptId) {
1122
+ const { isStale, result } = await runValidation("submit");
1123
+ if (thisSubmitAttemptId !== latestSubmitAttemptId) return;
1124
+ if (isStale) continue;
1125
+ if (result.issues) {
1126
+ replaceErrors([...result.issues]);
1127
+ return;
1128
+ }
1129
+ clearErrors();
1130
+ await config.onSubmit?.(result.value);
868
1131
  return;
869
1132
  }
870
- clearErrors();
871
- await config.onSubmit?.(result.value);
872
1133
  } finally {
873
- unmarkFieldsAsValidating(paths);
874
- isSubmitting.value = false;
1134
+ if (thisSubmitAttemptId === latestSubmitAttemptId) isSubmitting.value = false;
875
1135
  }
876
1136
  }
877
- /**
878
- * Restores values and errors to the baseline. Optional arguments become
879
- * the new baseline and fully replace the previous one — see
880
- * {@linkcode NotFormInstance.reset} for the "replace, not merge" detail.
881
- * @param nextValues New baseline values.
882
- * @param nextErrors New baseline issues.
883
- */
884
1137
  function reset(nextValues, nextErrors) {
885
- generation++;
886
- fieldValidationCycleMap.clear();
887
- validatingFieldCounts.clear();
888
- validatingFields.clear();
889
- if (nextValues) {
890
- const freshValues = klona(nextValues);
891
- for (const key of Object.keys(initialValues)) deleteProperty(initialValues, key);
892
- Object.assign(initialValues, freshValues);
893
- }
894
- if (nextErrors) initialErrors.splice(0, initialErrors.length, ...klona(nextErrors));
895
- for (const key of Object.keys(values)) deleteProperty(values, key);
896
- Object.assign(values, klona(initialValues));
897
- replaceErrors(klona(initialErrors));
898
- unmarkAllFieldsAsTouched();
899
- unmarkAllFieldsAsDirty();
1138
+ const freshValues = isEvent(nextValues) ? void 0 : nextValues;
1139
+ validationRuns.markAllRunsStale();
1140
+ cancelAllFieldTasks();
1141
+ latestSubmitAttemptId++;
1142
+ isSubmitting.value = false;
1143
+ if (freshValues !== void 0) initialValues.value = klona(toRaw(freshValues));
1144
+ if (nextErrors !== void 0) initialErrors.value = klona(toRaw(nextErrors));
1145
+ replaceRecordContents(values, klona(initialValues.value));
1146
+ replaceErrors(klona(initialErrors.value));
1147
+ touchedFields.clear();
1148
+ submitCount.value = 0;
1149
+ resetCount.value++;
900
1150
  }
1151
+ /** The root scope: `form.field(path)` and `form.arrayField(path)` live here. */
1152
+ const rootScope = createFieldScope({
1153
+ cancelFieldTask,
1154
+ getFieldErrors,
1155
+ getInitialValue: (path) => {
1156
+ return getValueAtPath(initialValues.value, path);
1157
+ },
1158
+ getResetCount: () => {
1159
+ return resetCount.value;
1160
+ },
1161
+ getValue,
1162
+ isFieldTouched,
1163
+ isFieldValidating,
1164
+ remapArrayErrors,
1165
+ scheduleFieldTask,
1166
+ setValue,
1167
+ touchField,
1168
+ untouchField,
1169
+ validateField
1170
+ }, () => {
1171
+ return "";
1172
+ });
1173
+ if (getCurrentScope()) onScopeDispose(() => {
1174
+ validationRuns.markAllRunsStale();
1175
+ cancelAllFieldTasks();
1176
+ rootScope.dispose();
1177
+ });
901
1178
  return reactive({
1179
+ arrayField: rootScope.arrayField,
902
1180
  clearErrors,
903
- dirtyFields,
904
1181
  errors,
1182
+ field: rootScope.field,
905
1183
  getFieldErrors,
1184
+ getValue,
1185
+ initialErrors,
1186
+ initialValues,
906
1187
  isDirty,
1188
+ isFieldValidating,
1189
+ isSubmitted,
907
1190
  isSubmitting,
908
1191
  isTouched,
909
1192
  isValid,
910
1193
  isValidating,
911
- markAllFieldsAsDirty,
912
- markAllFieldsAsTouched,
913
- markFieldAsDirty,
914
- markFieldAsTouched,
915
1194
  replaceErrors,
916
1195
  reset,
1196
+ resetCount,
917
1197
  setError,
918
1198
  setValue,
919
1199
  submit,
920
- syncAllDirtyStates,
921
- syncDirtyState,
922
- touchedFields,
923
- unmarkAllFieldsAsDirty,
924
- unmarkAllFieldsAsTouched,
925
- unmarkFieldAsDirty,
926
- unmarkFieldAsTouched,
1200
+ submitCount,
927
1201
  validate,
928
1202
  validateField,
929
- validatingFields,
930
1203
  values
931
1204
  });
932
1205
  }
933
- //#endregion
934
- //#region src/composables/use-not-form.ts
935
1206
  /**
936
- * Creates a form instance and returns the public {@linkcode NotFormAPI}.
937
- * @template TSchema The form schema.
938
- * @param config Schema, initial values/errors, and submit handler.
939
- * @returns Public form API. Child components still inject the full instance via `<NotForm>`.
1207
+ * Tells an event apart from starting values.
1208
+ * @param value The first argument given to `reset`.
1209
+ * @returns `true` if it is a DOM event.
1210
+ */
1211
+ function isEvent(value) {
1212
+ return typeof Event !== "undefined" && value instanceof Event;
1213
+ }
1214
+ /**
1215
+ * Empties a reactive record and refills it, keeping the same object identity.
1216
+ * @param target The record to replace the contents of.
1217
+ * @param source The new contents.
1218
+ */
1219
+ function replaceRecordContents(target, source) {
1220
+ for (const key of Object.keys(target)) delete target[key];
1221
+ Object.assign(target, source);
1222
+ }
1223
+ // src/composables/use-form.ts
1224
+ /**
1225
+ * Creates a reactive, schema-validated form. Call it inside `setup()`, so its
1226
+ * watchers and pending timers are cleaned up when the component unmounts.
1227
+ * @template TSchema - A Standard Schema (Zod, Valibot, ArkType, ...) describing the form's data. Must be an object schema.
1228
+ * @param config The schema, optional starting values/errors, and an optional submit handler.
1229
+ * @returns The form. Pass it to `<Form :form />` and use `form.field(path)` / `form.arrayField(path)` to create fields.
1230
+ * @example
1231
+ * ```vue
1232
+ * <script setup lang="ts">
1233
+ * import { z } from 'zod'
1234
+ * import { Field, Form, useForm } from 'notform'
1235
+ *
1236
+ * const form = useForm({
1237
+ * schema: z.object({ email: z.email() }),
1238
+ * initialValues: { email: '' },
1239
+ * onSubmit: async (data) => {
1240
+ * await api.signUp(data) // `data` is the validated output
1241
+ * },
1242
+ * })
1243
+ * <\/script>
1244
+ *
1245
+ * <template>
1246
+ * <Form :form>
1247
+ * <Field v-slot="{ field }" :field="form.field('email')">
1248
+ * <input v-model="field.model" v-bind="field.events" type="email">
1249
+ * </Field>
1250
+ * <button type="submit" :disabled="form.isSubmitting">Sign up</button>
1251
+ * </Form>
1252
+ * </template>
1253
+ * ```
940
1254
  */
941
- function useNotForm(config) {
942
- return createNotFormInstance(config);
1255
+ function useForm(config) {
1256
+ return createForm(config);
943
1257
  }
944
- //#endregion
945
- export { not_array_field_default as NotArrayField, not_field_default as NotField, not_form_default as NotForm, not_message_default as NotMessage, useNotForm };
1258
+ export { array_field_default as ArrayField, field_default as Field, field_error_default as FieldError, form_default as Form, useForm };