notform 2.2.5 → 3.0.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 +77 -67
  2. package/dist/index.d.ts +402 -413
  3. package/dist/index.js +719 -644
  4. package/package.json +14 -13
package/dist/index.js CHANGED
@@ -1,162 +1,391 @@
1
- import { computed, createBlock, createCommentVNode, createElementBlock, createTextVNode, defineComponent, inject, markRaw, mergeProps, nextTick, normalizeProps, onMounted, onUnmounted, openBlock, provide, reactive, ref, renderSlot, resolveDynamicComponent, toDisplayString, toValue, watch, withCtx, withModifiers } from "vue";
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";
2
3
  import { dequal } from "dequal";
3
- import { deepKeys, deleteProperty, getProperty, hasProperty, parsePath, setProperty } from "dot-prop";
4
- import { klona } from "klona/full";
5
- //#region src/utils/form.ts
4
+ import { klona } from "klona";
5
+ //#region src/utils/segments.ts
6
6
  /**
7
- * Normalizes a validation path segment into a standard property key.
8
- * @param segment The path segment to normalize.
9
- * @returns The normalized key.
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.
10
11
  */
11
- function normalizeSegment(segment) {
12
+ function toPropertyKey(segment) {
12
13
  if (typeof segment === "object" && segment !== null && "key" in segment) return segment.key;
13
14
  return segment;
14
15
  }
15
16
  /**
16
- * Compares two individual path segments for equality
17
- * @param firstSegment The first segment to compare.
18
- * @param secondSegment The second segment to compare.
19
- * @returns True if the two segments refer to the same object key or array index.
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.
20
22
  */
21
- function arePathSegmentsEqual(firstSegment, secondSegment) {
22
- if (typeof firstSegment === "number" || typeof secondSegment === "number") return Number(firstSegment) === Number(secondSegment);
23
- return firstSegment === secondSegment;
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;
24
29
  }
25
30
  /**
26
- * Checks if a validation issue path matches a target field path.
27
- * @param issuePath The path array from the validation issue.
28
- * @param targetPath The normalized path to compare against.
29
- * @returns True if the paths are equivalent.
31
+ * Whether `pathSegments` names the same field as `scopeSegments`, or a field
32
+ * nested underneath it, at any depth.
33
+ *
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.
30
48
  */
31
- function isIssuePathEqual(issuePath, targetPath) {
32
- if (!issuePath) return false;
33
- if (issuePath.length !== targetPath.length) return false;
34
- return issuePath.every((segment, segmentIndex) => {
35
- const normalizedSegment = normalizeSegment(segment);
36
- const targetSegment = targetPath[segmentIndex];
37
- return arePathSegmentsEqual(normalizedSegment, targetSegment);
38
- });
49
+ function isPathWithinScope(pathSegments, scopeSegments) {
50
+ if (pathSegments.length < scopeSegments.length) return false;
51
+ return scopeSegments.every((scopeSegment, segmentIndex) => areSegmentsEqual(pathSegments[segmentIndex], scopeSegment));
39
52
  }
40
53
  //#endregion
41
54
  //#region src/utils/array-field.ts
42
55
  /**
43
- * Finds where a path sits inside an array field, if at all.
44
- * @param pathSegments The full path segments to test, e.g. the parsed segments of `"tags.1.label"`.
45
- * @param arrayFieldPathSegments The parsed path segments of the array field itself, e.g. the segments of `"tags"`.
46
- * @returns The item's index and any trailing segments, or undefined if the path isn't inside this array.
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.
47
65
  */
48
- function getArrayItemLocation(pathSegments, arrayFieldPathSegments) {
49
- if (pathSegments.length <= arrayFieldPathSegments.length) return;
50
- for (const [segmentIndex, arrayFieldPathSegment] of arrayFieldPathSegments.entries()) if (!arePathSegmentsEqual(pathSegments[segmentIndex], arrayFieldPathSegment)) return;
51
- const itemIndex = Number(pathSegments[arrayFieldPathSegments.length]);
52
- if (Number.isNaN(itemIndex)) return;
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;
53
72
  return {
54
- itemIndex,
55
- remainingSegments: pathSegments.slice(arrayFieldPathSegments.length + 1)
73
+ index: itemIndex,
74
+ remainingPathSegments: pathSegments.slice(arrayFieldPathSegments.length + 1)
56
75
  };
57
76
  }
58
77
  /**
59
- * Rebuilds a dot-separated path from an array field's path, an item index, and any trailing segments.
60
- * @param arrayFieldPath The array field's dot-separated path, e.g. `"tags"`.
61
- * @param itemIndex The item's index within the array.
62
- * @param remainingSegments Segments after the index, e.g. `['label']` for a nested field.
63
- * @returns The reconstructed path, e.g. `"tags.0.label"`.
64
- */
65
- function buildArrayItemPath(arrayFieldPath, itemIndex, remainingSegments) {
66
- return [
67
- arrayFieldPath,
68
- itemIndex,
69
- ...remainingSegments
70
- ].join(".");
71
- }
72
- /**
73
- * Relocates or drops `form.errors` entries that belong to an item that moved or was removed.
74
- * Errors on the array field itself (no index segment) are left untouched.
75
- * @param form The form instance whose errors should be remapped.
76
- * @param arrayFieldPathSegments The parsed path segments of the array field.
77
- * @param arrayItemIndexMap Maps each previous item index to its new index, or undefined if removed.
78
- */
79
- function remapArrayFieldErrors(form, arrayFieldPathSegments, arrayItemIndexMap) {
80
- for (let errorIndex = form.errors.length - 1; errorIndex >= 0; errorIndex--) {
81
- const issue = form.errors[errorIndex];
82
- if (issue.path) {
83
- const itemLocation = getArrayItemLocation(issue.path.map((segment) => normalizeSegment(segment)), arrayFieldPathSegments);
84
- if (itemLocation) {
85
- const newItemIndex = arrayItemIndexMap(itemLocation.itemIndex);
86
- if (typeof newItemIndex === "number") form.errors[errorIndex] = {
87
- ...issue,
88
- path: [
89
- ...arrayFieldPathSegments,
90
- newItemIndex,
91
- ...itemLocation.remainingSegments
92
- ]
93
- };
94
- else form.errors.splice(errorIndex, 1);
95
- }
96
- }
97
- }
98
- }
99
- /**
100
- * Relocates or drops entries in a `touchedFields`/`dirtyFields` set that belong to an item that moved or was removed.
101
- * @param fieldPathSet The reactive set of field paths to remap.
102
- * @param arrayFieldPath The dot-separated path of the array field.
103
- * @param arrayFieldPathSegments The parsed path segments of the array field.
104
- * @param arrayItemIndexMap Maps each previous item index to its new index, or undefined if removed.
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.
105
84
  */
106
- function remapArrayFieldPathSet(fieldPathSet, arrayFieldPath, arrayFieldPathSegments, arrayItemIndexMap) {
85
+ function remapPathSet(paths, arraySegments, remapIndex) {
107
86
  const pathsToRemove = [];
108
87
  const pathsToAdd = [];
109
- for (const fieldPath of fieldPathSet) {
110
- const itemLocation = getArrayItemLocation(parsePath(fieldPath), arrayFieldPathSegments);
111
- if (itemLocation) {
112
- pathsToRemove.push(fieldPath);
113
- const newItemIndex = arrayItemIndexMap(itemLocation.itemIndex);
114
- if (typeof newItemIndex === "number") pathsToAdd.push(buildArrayItemPath(arrayFieldPath, newItemIndex, itemLocation.remainingSegments));
115
- }
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);
116
100
  }
117
- for (const fieldPath of pathsToRemove) fieldPathSet.delete(fieldPath);
118
- for (const fieldPath of pathsToAdd) fieldPathSet.add(fieldPath);
101
+ for (const path of pathsToRemove) paths.delete(path);
102
+ for (const path of pathsToAdd) paths.add(path);
119
103
  }
120
104
  /**
121
- * Keeps an array field's errors, touched paths, and dirty paths attached to the item they
122
- * describe, rather than the array slot that item happened to occupy. Call before applying
123
- * the mutation to `form.values`.
124
- * @param form The form instance whose error, touched, and dirty state should be remapped.
125
- * @param arrayFieldPath The dot-separated path of the array field being mutated, e.g. `"tags"`.
126
- * @param arrayItemIndexMap Maps each previous item index to its new index, or undefined if removed.
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.
110
+ *
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.
127
117
  */
128
- function remapArrayFieldState(form, arrayFieldPath, arrayItemIndexMap) {
129
- const arrayFieldPathSegments = parsePath(arrayFieldPath);
130
- remapArrayFieldErrors(form, arrayFieldPathSegments, arrayItemIndexMap);
131
- remapArrayFieldPathSet(form.touchedFields, arrayFieldPath, arrayFieldPathSegments, arrayItemIndexMap);
132
- remapArrayFieldPathSet(form.dirtyFields, arrayFieldPath, arrayFieldPathSegments, arrayItemIndexMap);
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);
133
145
  }
134
146
  //#endregion
135
147
  //#region src/utils/instance.ts
136
- /** The injection key for the NotForm instance. */
148
+ /**
149
+ * Injection key for the full {@linkcode NotFormInstance}.
150
+ * @internal
151
+ */
137
152
  const NOT_FORM_INSTANCE_KEY = Symbol("notform:instance");
138
153
  /**
139
- * Provides the NotForm instance to the component tree.
140
- * @template TSchema The validation object schema.
141
- * @param instance The NotForm instance to provide.
154
+ * Provides the full form instance to descendants.
155
+ * @template TSchema The form schema.
156
+ * @internal
157
+ * @param instance Public API or full instance.
142
158
  */
143
159
  function provideNotFormInstance(instance) {
144
- provide(NOT_FORM_INSTANCE_KEY, instance);
160
+ provide(NOT_FORM_INSTANCE_KEY, toNotFormInstance(instance));
161
+ }
162
+ function toNotFormInstance(form) {
163
+ return form;
145
164
  }
165
+ //#endregion
166
+ //#region src/composables/use-not-form-instance.ts
146
167
  /**
147
- * Retrieves the NotForm instance from the component tree.
168
+ * Resolves the full form instance from `explicitInstance` or inject.
148
169
  * @template TSchema The form schema.
149
- * @param explicitInstance Optional explicit instance to use.
150
- * @returns The NotForm instance.
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.
151
174
  */
152
175
  function useNotFormInstance(explicitInstance) {
153
- const injected = inject(NOT_FORM_INSTANCE_KEY, void 0);
154
- const instance = explicitInstance ?? injected;
155
- if (!instance) throw new Error(`
156
- [NotForm] No form instance found.
157
- Wrap with <NotForm :form="..."> or pass :form directly to the field.
158
- `);
159
- return instance;
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;
183
+ }
184
+ //#endregion
185
+ //#region src/composables/use-not-array-field.ts
186
+ /**
187
+ * Array-field state, stable item keys, and mutation helpers for `<NotArrayField>`.
188
+ *
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.
192
+ *
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 : [];
215
+ });
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));
235
+ });
236
+ /** Whether the array field's own path, or any path nested under it, has been touched. */
237
+ const isTouched = computed(() => {
238
+ return [...form.touchedFields].some((touchedPath) => isPathWithinScope(parsePath(touchedPath), arrayPathSegments.value));
239
+ });
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));
243
+ });
244
+ /** Whether the array field's own path, or any path nested under it, is currently validating. */
245
+ const isValidating = computed(() => {
246
+ return [...form.validatingFields].some((validatingPath) => isPathWithinScope(parsePath(validatingPath), arrayPathSegments.value));
247
+ });
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++}`;
254
+ }
255
+ /**
256
+ * Pads or trims `itemKeys` from the end so it matches `targetLength`.
257
+ * @param targetLength Desired key count.
258
+ */
259
+ function syncItemKeysToLength(targetLength) {
260
+ while (itemKeys.value.length < targetLength) itemKeys.value.push(createItemKey());
261
+ if (itemKeys.value.length > targetLength) itemKeys.value.length = targetLength;
262
+ }
263
+ /**
264
+ * Returns the mutable array at `props.path`, creating an empty array if needed.
265
+ * @returns The live array stored on the form.
266
+ */
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;
273
+ }
274
+ /**
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.
280
+ */
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);
286
+ }
287
+ /**
288
+ * Appends `value` and a new key at the end of the array.
289
+ * @param value Item to append.
290
+ */
291
+ function append(value) {
292
+ getOrCreateArrayValue().push(value);
293
+ itemKeys.value.push(createItemKey());
294
+ }
295
+ /**
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.
299
+ */
300
+ function prepend(value) {
301
+ getOrCreateArrayValue().unshift(value);
302
+ itemKeys.value.unshift(createItemKey());
303
+ remapArrayFieldState(form, props.path, (previousIndex) => previousIndex + 1);
304
+ }
305
+ /**
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.
310
+ */
311
+ 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);
315
+ }
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
+ 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
+ });
329
+ }
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
+ function update(index, value) {
336
+ getOrCreateArrayValue()[index] = value;
337
+ }
338
+ /**
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.
343
+ */
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;
352
+ });
353
+ }
354
+ /**
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.
359
+ */
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
+ });
368
+ }
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
388
+ });
160
389
  }
161
390
  //#endregion
162
391
  //#region src/components/not-array-field.vue
@@ -164,7 +393,7 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
164
393
  __name: "not-array-field",
165
394
  props: {
166
395
  path: {
167
- type: String,
396
+ type: [String, Object],
168
397
  required: true
169
398
  },
170
399
  itemSchema: {
@@ -172,357 +401,137 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
172
401
  required: false
173
402
  },
174
403
  form: {
175
- type: null,
176
- required: false
177
- },
178
- validateOn: {
179
404
  type: Object,
180
405
  required: false
181
406
  }
182
407
  },
183
408
  setup(__props) {
184
- const props = __props;
185
- const form = useNotFormInstance(props.form);
186
- const isValidating = ref(false);
187
- const validatingCount = ref(0);
188
- /** Counter to ensure absolute uniqueness for generated keys. */
189
- const keyCounter = ref(0);
190
- /** Stable keys per item that survive reorders, removals, and inserts. */
191
- const itemKeys = ref((() => {
192
- const initial = getProperty(form.values, props.path);
193
- const length = Array.isArray(initial) ? initial.length : 0;
194
- return Array.from({ length }, () => `${props.path}-${keyCounter.value++}`);
195
- })());
196
- const validateOn = computed(() => ({
197
- onChange: props.validateOn?.onChange ?? form.validateOn.onChange,
198
- onMount: props.validateOn?.onMount ?? form.validateOn.onMount
199
- }));
200
- const array = computed(() => {
201
- const value = getProperty(form.values, props.path);
202
- return Array.isArray(value) ? value : [];
203
- });
204
- const items = computed(() => {
205
- return array.value.map((_, index) => ({
206
- index,
207
- key: itemKeys.value[index] ?? `${props.path}-fallback-${index}`,
208
- path: `${props.path}.${index}`
209
- }));
210
- });
211
- const errors = computed(() => form.getFieldErrors(props.path));
212
- const isValid = computed(() => errors.value.length === 0);
213
- const isTouched = computed(() => {
214
- return form.touchedFields.has(props.path) || [...form.touchedFields].some((path) => path.startsWith(`${props.path}.`));
215
- });
216
- const isDirty = computed(() => {
217
- return form.dirtyFields.has(props.path) || [...form.dirtyFields].some((path) => path.startsWith(`${props.path}.`));
218
- });
219
- /**
220
- * Starts a local field validation execution.
221
- */
222
- function beginValidation() {
223
- validatingCount.value++;
224
- isValidating.value = true;
225
- }
226
- /**
227
- * Ends a local field validation execution.
228
- */
229
- function endValidation() {
230
- validatingCount.value--;
231
- if (validatingCount.value === 0) isValidating.value = false;
232
- }
233
- /**
234
- * Generates a unique key string using the field path and counter.
235
- * @returns A unique key string.
236
- */
237
- function generateKey() {
238
- return `${props.path}-${keyCounter.value++}`;
239
- }
240
- /**
241
- * Re-aligns itemKeys with current array length.
242
- *
243
- * Used mostly after external resets.
244
- */
245
- function syncKeys() {
246
- const arrayLength = array.value.length;
247
- if (itemKeys.value.length > arrayLength) itemKeys.value.length = arrayLength;
248
- else while (itemKeys.value.length < arrayLength) itemKeys.value.push(generateKey());
249
- }
250
- /**
251
- * Applies an array update, syncs form dirty/touched state, and triggers validation.
252
- * @param updater Function that modifies the array.
253
- */
254
- function mutate(updater) {
255
- const current = [...array.value];
256
- updater(current);
257
- setProperty(form.values, props.path, current);
258
- form.touchField(props.path);
259
- if (dequal(current, getProperty(form.initialValues, props.path))) form.dirtyFields.delete(props.path);
260
- else form.dirtyField(props.path);
261
- if (validateOn.value.onChange) validate();
262
- }
263
- /**
264
- * Validates the array field.
265
- * @returns A promise that resolves to the validation result.
266
- */
267
- async function validate() {
268
- beginValidation();
269
- try {
270
- return await form.validateField(props.path);
271
- } finally {
272
- endValidation();
273
- }
274
- }
275
- /**
276
- * Appends an item to the array.
277
- * @param value The value to append.
278
- */
279
- function append(value) {
280
- itemKeys.value.push(generateKey());
281
- mutate((current) => {
282
- current.push(value);
283
- });
284
- }
285
- /**
286
- * Prepends an item to the array.
287
- * @param value The value to prepend.
288
- */
289
- function prepend(value) {
290
- itemKeys.value.unshift(generateKey());
291
- remapArrayFieldState(form, props.path, (previousIndex) => previousIndex + 1);
292
- mutate((current) => {
293
- current.unshift(value);
294
- });
295
- }
296
- /**
297
- * Removes an item from the array.
298
- * @param index The item index to remove.
299
- */
300
- function remove(index) {
301
- itemKeys.value.splice(index, 1);
302
- remapArrayFieldState(form, props.path, (previousIndex) => {
303
- if (previousIndex === index) return;
304
- return previousIndex > index ? previousIndex - 1 : previousIndex;
305
- });
306
- mutate((current) => current.splice(index, 1));
307
- }
308
- /**
309
- * Inserts an item at a specific index.
310
- * @param index The insertion index.
311
- * @param value The value to insert.
312
- */
313
- function insert(index, value) {
314
- itemKeys.value.splice(index, 0, generateKey());
315
- remapArrayFieldState(form, props.path, (previousIndex) => {
316
- if (previousIndex >= index) return previousIndex + 1;
317
- return previousIndex;
318
- });
319
- mutate((current) => current.splice(index, 0, value));
320
- }
321
- /**
322
- * Updates an item at a specific index.
323
- * @param index The item index to update.
324
- * @param value The new item value.
325
- */
326
- function update(index, value) {
327
- mutate((current) => {
328
- current[index] = value;
329
- });
330
- }
331
- /**
332
- * Swaps two items in the array.
333
- * @param indexA The first item index.
334
- * @param indexB The second item index.
335
- */
336
- function swap(indexA, indexB) {
337
- const temporaryKey = itemKeys.value[indexA];
338
- itemKeys.value[indexA] = itemKeys.value[indexB];
339
- itemKeys.value[indexB] = temporaryKey;
340
- remapArrayFieldState(form, props.path, (previousIndex) => {
341
- if (previousIndex === indexA) return indexB;
342
- if (previousIndex === indexB) return indexA;
343
- return previousIndex;
344
- });
345
- mutate((current) => {
346
- const temporaryValue = current[indexA];
347
- current[indexA] = current[indexB];
348
- current[indexB] = temporaryValue;
349
- });
350
- }
351
- /**
352
- * Moves an item from one index to another.
353
- * @param from The source index.
354
- * @param to The destination index.
355
- */
356
- function move(from, to) {
357
- const [movedItemKey] = itemKeys.value.splice(from, 1);
358
- itemKeys.value.splice(to, 0, movedItemKey);
359
- remapArrayFieldState(form, props.path, (previousIndex) => {
360
- if (previousIndex === from) return to;
361
- if (from < to) {
362
- if (previousIndex > from && previousIndex <= to) return previousIndex - 1;
363
- return previousIndex;
364
- }
365
- if (previousIndex >= to && previousIndex < from) return previousIndex + 1;
366
- return previousIndex;
367
- });
368
- mutate((current) => {
369
- const [movedItem] = current.splice(from, 1);
370
- current.splice(to, 0, movedItem);
371
- });
372
- }
373
- onMounted(async () => {
374
- await nextTick();
375
- if (validateOn.value.onMount) validate();
376
- });
377
- watch(() => array.value.length, syncKeys);
409
+ const arrayField = useNotArrayField(__props);
378
410
  return (_ctx, _cache) => {
379
- return renderSlot(_ctx.$slots, "default", {
380
- append,
381
- errors: errors.value,
382
- insert,
383
- isDirty: isDirty.value,
384
- isTouched: isTouched.value,
385
- isValid: isValid.value,
386
- isValidating: isValidating.value,
387
- items: items.value,
388
- move,
389
- path: __props.path,
390
- prepend,
391
- remove,
392
- swap,
393
- update,
394
- validate
395
- });
411
+ return renderSlot(_ctx.$slots, "default", normalizeProps(guardReactiveProps(unref(arrayField))));
396
412
  };
397
413
  }
398
414
  });
399
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;
448
+ }
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);
459
+ }
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();
465
+ }
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();
471
+ }
472
+ /**
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.
476
+ */
477
+ function onMount() {
478
+ if (validateOn.value?.onMount) form.validateField(props.path);
479
+ }
480
+ const events = {
481
+ onBlur,
482
+ onChange,
483
+ onInput
484
+ };
485
+ onMounted(async () => {
486
+ await nextTick();
487
+ onMount();
488
+ });
489
+ onUnmounted(() => {
490
+ clearDebounce();
491
+ });
492
+ return reactive({
493
+ errors,
494
+ events,
495
+ isDirty,
496
+ isTouched,
497
+ isValid,
498
+ isValidating,
499
+ path: computed(() => props.path)
500
+ });
501
+ }
502
+ //#endregion
400
503
  //#region src/components/not-field.vue
401
504
  var not_field_default = /* @__PURE__ */ defineComponent({
402
505
  __name: "not-field",
403
506
  props: {
404
507
  path: {
405
- type: String,
508
+ type: [String, Object],
406
509
  required: true
407
510
  },
408
511
  form: {
409
- type: null,
512
+ type: Object,
410
513
  required: false
411
514
  },
412
515
  validateOn: {
413
516
  type: Object,
414
- required: false
517
+ required: false,
518
+ default: void 0
519
+ },
520
+ validationMode: {
521
+ type: String,
522
+ required: false,
523
+ default: "eager"
415
524
  },
416
525
  debounce: {
417
526
  type: Number,
418
- required: false
527
+ required: false,
528
+ default: void 0
419
529
  }
420
530
  },
421
531
  setup(__props) {
422
- const props = __props;
423
- const form = useNotFormInstance(props.form);
424
- const isValidating = ref(false);
425
- const validatingCount = ref(0);
426
- /** Timer handle for the current pending debounced validation. */
427
- const debounceTimer = ref();
428
- /** Merges per-field overrides with the form-wide validation config. */
429
- const validateOn = computed(() => ({
430
- ...form.validateOn,
431
- ...props.validateOn
432
- }));
433
- const value = computed(() => getProperty(form.values, props.path));
434
- const errors = computed(() => form.getFieldErrors(props.path));
435
- const isValid = computed(() => errors.value.length === 0);
436
- const isTouched = computed(() => form.touchedFields.has(props.path));
437
- const isDirty = computed(() => form.dirtyFields.has(props.path));
438
- /** Starts a local field validation execution. */
439
- function beginValidation() {
440
- validatingCount.value++;
441
- isValidating.value = true;
442
- }
443
- /** Ends a local field validation execution. */
444
- function endValidation() {
445
- validatingCount.value--;
446
- if (validatingCount.value === 0) isValidating.value = false;
447
- }
448
- /** Cancels pending debounced validation on blur/unmount to prevent rogue execution. */
449
- function clearDebounce() {
450
- if (debounceTimer.value === void 0) return;
451
- clearTimeout(debounceTimer.value);
452
- debounceTimer.value = void 0;
453
- }
454
- /** Compares current value against baseline to sync dirty state tracking. */
455
- function updateDirty() {
456
- if (dequal(value.value, getProperty(form.initialValues, props.path))) form.dirtyFields.delete(props.path);
457
- else form.dirtyField(props.path);
458
- }
459
- /** Replaces pending debounced validation with a new timer, or runs immediately when no debounce is configured. */
460
- function scheduleValidation() {
461
- if (!props.debounce) {
462
- validate();
463
- return;
464
- }
465
- clearDebounce();
466
- debounceTimer.value = setTimeout(validate, props.debounce);
467
- }
468
- /**
469
- * Validates the field and returns the validation result.
470
- * @returns A promise that resolves to the validation result.
471
- */
472
- async function validate() {
473
- beginValidation();
474
- try {
475
- return await form.validateField(props.path);
476
- } finally {
477
- endValidation();
478
- }
479
- }
480
- /** Handles the blur event for the field. */
481
- function onBlur() {
482
- clearDebounce();
483
- form.touchField(props.path);
484
- if (validateOn.value.onBlur) validate();
485
- }
486
- /** Handles the input event for the field. */
487
- function onInput() {
488
- updateDirty();
489
- if (!validateOn.value.onInput) return;
490
- if (form.validationMode === "eager" && !isValid.value) scheduleValidation();
491
- }
492
- /** Handles the change event for the field. */
493
- function onChange() {
494
- updateDirty();
495
- if (!validateOn.value.onChange) return;
496
- if (form.validationMode === "eager" && !isValid.value) scheduleValidation();
497
- }
498
- /** Handles the focus event for the field. */
499
- function onFocus() {
500
- if (validateOn.value.onFocus) scheduleValidation();
501
- }
502
- onMounted(async () => {
503
- await nextTick();
504
- if (validateOn.value.onMount) validate();
505
- });
506
- onUnmounted(() => {
507
- clearDebounce();
508
- });
532
+ const field = useNotField(__props);
509
533
  return (_ctx, _cache) => {
510
- return renderSlot(_ctx.$slots, "default", {
511
- errors: errors.value,
512
- isValid: isValid.value,
513
- isTouched: isTouched.value,
514
- isDirty: isDirty.value,
515
- isValidating: isValidating.value,
516
- validate,
517
- events: {
518
- onBlur,
519
- onChange,
520
- onFocus,
521
- onInput
522
- },
523
- path: __props.path,
524
- value: value.value
525
- });
534
+ return renderSlot(_ctx.$slots, "default", normalizeProps(guardReactiveProps(unref(field))));
526
535
  };
527
536
  }
528
537
  });
@@ -531,13 +540,13 @@ var not_field_default = /* @__PURE__ */ defineComponent({
531
540
  var not_form_default = /* @__PURE__ */ defineComponent({
532
541
  __name: "not-form",
533
542
  props: { form: {
534
- type: null,
543
+ type: Object,
535
544
  required: true
536
545
  } },
537
546
  setup(__props) {
538
547
  provideNotFormInstance(__props.form);
539
548
  return (_ctx, _cache) => {
540
- return openBlock(), createElementBlock("form", { onReset: _cache[0] || (_cache[0] = withModifiers(() => {}, ["prevent"])) }, [renderSlot(_ctx.$slots, "default")], 32);
549
+ 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", { onReset: _cache[0] || (_cache[0] = withModifiers(() => {}, ["prevent"])) }, [renderSlot(_ctx.$slots, "default")], 32)], 2112);
541
550
  };
542
551
  }
543
552
  });
@@ -553,18 +562,18 @@ var not_message_default = /* @__PURE__ */ defineComponent({
553
562
  default: "span"
554
563
  },
555
564
  path: {
556
- type: String,
565
+ type: [String, Object],
557
566
  required: true
558
567
  },
559
568
  form: {
560
- type: null,
569
+ type: Object,
561
570
  required: false
562
571
  }
563
572
  },
564
573
  setup(__props) {
565
574
  const props = __props;
566
575
  const form = useNotFormInstance(props.form);
567
- const message = computed(() => form.errorsMap.value[props.path]);
576
+ const message = computed(() => form.getFieldErrors(props.path)[0]?.message);
568
577
  return (_ctx, _cache) => {
569
578
  return message.value ? (openBlock(), createBlock(resolveDynamicComponent(__props.as), normalizeProps(mergeProps({ key: 0 }, _ctx.$attrs)), {
570
579
  default: withCtx(() => [renderSlot(_ctx.$slots, "default", { message: message.value }, () => [createTextVNode(toDisplayString(message.value), 1)])]),
@@ -574,296 +583,362 @@ var not_message_default = /* @__PURE__ */ defineComponent({
574
583
  }
575
584
  });
576
585
  //#endregion
577
- //#region src/composables/use-not-form.ts
586
+ //#region src/utils/issues.ts
578
587
  /**
579
- * Creates a reactive NotFormInstance for managing form state and validation.
580
- * @template TSchema The standard schema type.
581
- * @param config Configuration object.
582
- * @returns A reactive NotFormInstance.
588
+ * Whether two issue paths point at the same field after segment normalization.
589
+ * @internal
590
+ * @param issuePathA First path.
591
+ * @param issuePathB Second path.
592
+ * @returns `true` when both paths are defined, same length, and equal segment-wise.
583
593
  */
584
- function useNotForm(config) {
585
- let initialValues = klona(config.initialValues ?? {});
586
- let initialErrors = klona(config.initialErrors ?? []);
587
- const validateOn = {
588
- onBlur: config.validateOn?.onBlur ?? true,
589
- onChange: config.validateOn?.onChange ?? true,
590
- onFocus: config.validateOn?.onFocus ?? false,
591
- onInput: config.validateOn?.onInput ?? true,
592
- onMount: config.validateOn?.onMount ?? false
593
- };
594
- const validationMode = config.validationMode || "eager";
594
+ function areIssuePathsEqual(issuePathA, issuePathB) {
595
+ if (!issuePathA || !issuePathB) return false;
596
+ if (issuePathA.length !== issuePathB.length) return false;
597
+ return issuePathA.every((segment, segmentIndex) => areSegmentsEqual(segment, issuePathB[segmentIndex]));
598
+ }
599
+ //#endregion
600
+ //#region src/factories/create-not-form-instance.ts
601
+ /**
602
+ * Builds the full reactive form instance used by `useNotForm` and field components.
603
+ * @template TSchema The form schema.
604
+ * @internal
605
+ * @param config Schema, initial values/errors, and submit handler.
606
+ * @returns Assembled {@linkcode NotFormInstance}.
607
+ */
608
+ function createNotFormInstance(config) {
609
+ /** Baseline values for reset. */
610
+ const initialValues = klona(config.initialValues ?? {});
611
+ /** Live field values. */
595
612
  const values = reactive(klona(initialValues));
613
+ /** Baseline issues for reset. */
614
+ const initialErrors = klona(config.initialErrors ?? []);
615
+ /** Current validation issues. */
596
616
  const errors = reactive([...initialErrors]);
617
+ /** Paths the user has interacted with. */
597
618
  const touchedFields = reactive(/* @__PURE__ */ new Set());
619
+ const isTouched = computed(() => touchedFields.size > 0);
620
+ /** Paths whose value differs from the baseline. */
598
621
  const dirtyFields = reactive(/* @__PURE__ */ new Set());
599
- const isSubmitting = ref(false);
600
- const isValid = computed(() => errors.length === 0);
601
622
  const isDirty = computed(() => dirtyFields.size > 0);
602
- const isTouched = computed(() => touchedFields.size > 0);
603
- const errorsMap = computed(() => {
604
- const result = {};
605
- for (const issue of errors) {
606
- if (!issue.path) continue;
607
- const path = issue.path.map((element) => normalizeSegment(element)).join(".");
608
- if (path && !Object.hasOwn(result, path)) result[path] = issue.message;
609
- }
610
- return result;
611
- });
612
- /** Whether one or more validation operations are currently running. */
613
- const isValidating = ref(false);
614
- /** Number of validation operations currently in progress. */
615
- const validatingCount = ref(0);
616
- /** Identifier of the latest validation cycle. */
617
- let validationCycle = 0;
618
- /** Identifier of the latest validation cycle for each field. */
619
- const fieldValidationCycles = /* @__PURE__ */ new Map();
623
+ /** Paths with an in-flight validation. */
624
+ const validatingFields = reactive(/* @__PURE__ */ new Set());
625
+ const isValid = computed(() => errors.length === 0);
626
+ const isValidating = computed(() => validatingFields.size > 0);
627
+ const isSubmitting = ref(false);
628
+ /**
629
+ * Bumped by validate, submit, and reset. Older in-flight runs are discarded:
630
+ * each still resolves with its own result, but only the run matching the
631
+ * current generation when it finishes gets to write to `errors`.
632
+ */
633
+ let generation = 0;
634
+ /** Latest per-field validation cycle id, used to drop stale field results. */
635
+ const fieldValidationCycleMap = /* @__PURE__ */ new Map();
636
+ /** Active validation count per path so overlapping runs do not clear too early. */
637
+ const validatingFieldCounts = /* @__PURE__ */ new Map();
620
638
  /**
621
- * Starts a validation operation.
622
- * @returns The identifier of the new validation cycle.
639
+ * Runs the current schema against `values`. Resolves `config.schema`
640
+ * first via `toValue`, so a ref or a getter function works the same as
641
+ * passing the schema directly.
642
+ * @returns Standard Schema result.
623
643
  */
624
- function beginValidation() {
625
- validatingCount.value++;
626
- isValidating.value = true;
627
- return ++validationCycle;
644
+ function executeSchemaValidation() {
645
+ return toValue(config.schema)["~standard"].validate(values);
628
646
  }
629
647
  /**
630
- * Starts a validation operation for a specific field path.
631
- * @param path The field path to start validation for.
632
- * @returns The identifier of the new validation cycle.
648
+ * Sets a field value and syncs its dirty flag. Does not validate.
649
+ * @template TPath Field path.
650
+ * @param path Dot path.
651
+ * @param value Value to assign.
633
652
  */
634
- function beginFieldValidation(path) {
635
- validatingCount.value++;
636
- isValidating.value = true;
637
- const key = String(path);
638
- const cycle = (fieldValidationCycles.get(key) ?? 0) + 1;
639
- fieldValidationCycles.set(key, cycle);
640
- return cycle;
653
+ function setValue(path, value) {
654
+ setProperty(values, path, value);
655
+ syncDirtyState(path);
641
656
  }
642
657
  /**
643
- * Checks if the validation cycle for a specific field path is current.
644
- * @param path The field path to check.
645
- * @param cycle The validation cycle identifier.
646
- * @returns Whether the cycle is still current.
658
+ * Upserts an issue at the same path, or appends it.
659
+ * @param error Issue to set.
647
660
  */
648
- function isCurrentFieldValidation(path, cycle) {
649
- return fieldValidationCycles.get(String(path)) === cycle;
661
+ function setError(error) {
662
+ const existingIndex = errors.findIndex((existing) => areIssuePathsEqual(existing.path, error.path));
663
+ if (existingIndex === -1) errors.push(error);
664
+ else errors[existingIndex] = error;
650
665
  }
651
- /** Marks a validation operation as finished. */
652
- function endValidation() {
653
- validatingCount.value--;
654
- if (validatingCount.value === 0) isValidating.value = false;
666
+ /**
667
+ * Replaces the entire error list.
668
+ * @internal
669
+ * @param nextErrors Replacement issues.
670
+ */
671
+ function replaceErrors(nextErrors) {
672
+ errors.splice(0, errors.length, ...nextErrors);
655
673
  }
656
674
  /**
657
- * Checks whether a validation cycle is still the latest cycle.
658
- * @param cycle The validation cycle identifier.
659
- * @returns Whether the cycle is still current.
675
+ * Removes every issue.
676
+ * @internal
660
677
  */
661
- function isCurrentValidation(cycle) {
662
- return cycle === validationCycle;
678
+ function clearErrors() {
679
+ errors.length = 0;
663
680
  }
664
681
  /**
665
- * Resolves and executes the standard schema validation.
666
- * @returns The validation result containing either `value` or `issues`.
682
+ * Issues whose path exactly equals `path` — see the same note on
683
+ * {@linkcode NotFormInstance.getFieldErrors}.
684
+ * @param path Dot path.
685
+ * @returns Matching issues.
667
686
  */
668
- function runSchema() {
669
- return toValue(config.schema)["~standard"].validate(values);
687
+ function getFieldErrors(path) {
688
+ const targetPath = parsePath(path);
689
+ return errors.filter((error) => areIssuePathsEqual(error.path, targetPath));
670
690
  }
671
- /** Iterates through deeply nested keys to mark all as touched. */
672
- function touchAllFields() {
673
- for (const path of deepKeys(values)) touchedFields.add(path);
691
+ /**
692
+ * Forces a field dirty.
693
+ * @internal
694
+ * @param path Dot path.
695
+ */
696
+ function markFieldAsDirty(path) {
697
+ dirtyFields.add(path);
698
+ }
699
+ /**
700
+ * Forces every current leaf field dirty. Only paths present in `values`
701
+ * right now are affected — see the note on
702
+ * {@linkcode NotFormInstance.markAllFieldsAsTouched}.
703
+ * @internal
704
+ */
705
+ function markAllFieldsAsDirty() {
706
+ for (const path of deepKeys(values)) dirtyFields.add(path);
674
707
  }
675
708
  /**
676
- * Removes a specific field path from the dirty tracking set.
677
- * @param path The field path to mark as clean.
709
+ * Clears dirty state for one field.
710
+ * @internal
711
+ * @param path Dot path.
678
712
  */
679
- function unDirtyField(path) {
713
+ function unmarkFieldAsDirty(path) {
680
714
  dirtyFields.delete(path);
681
715
  }
682
- /** Iterates through deeply nested keys to mark all as dirty. */
683
- function dirtyAllFields() {
684
- for (const path of deepKeys(values)) dirtyFields.add(path);
716
+ /**
717
+ * Clears dirty state for all fields.
718
+ * @internal
719
+ */
720
+ function unmarkAllFieldsAsDirty() {
721
+ dirtyFields.clear();
685
722
  }
686
723
  /**
687
- * Marks a specific field path as touched.
688
- * @param path The field path to mark as touched.
724
+ * Sets dirty from a deep compare against the baseline.
725
+ * @internal
726
+ * @param path Dot path.
689
727
  */
690
- function touchField(path) {
691
- touchedFields.add(path);
728
+ function syncDirtyState(path) {
729
+ const currentValue = getProperty(values, path);
730
+ const baselineValue = getProperty(initialValues, path);
731
+ if (dequal(currentValue, baselineValue)) unmarkFieldAsDirty(path);
732
+ else markFieldAsDirty(path);
692
733
  }
693
734
  /**
694
- * Marks a specific field path as dirty.
695
- * @param path The field path to mark as dirty.
735
+ * Recalculates dirty state for every current leaf path.
736
+ * @internal
696
737
  */
697
- function dirtyField(path) {
698
- dirtyFields.add(path);
738
+ function syncAllDirtyStates() {
739
+ for (const path of deepKeys(values)) syncDirtyState(path);
699
740
  }
700
741
  /**
701
- * Sets a value at the specified path and updates dirty tracking based on comparison with initial values.
702
- * @param path The field path to set the value at.
703
- * @param value The value to set.
742
+ * Marks a field as touched.
743
+ * @internal
744
+ * @param path Dot path.
704
745
  */
705
- function setValue(path, value) {
706
- setProperty(values, path, value);
707
- if (dequal(value, getProperty(initialValues, path))) unDirtyField(path);
708
- else dirtyField(path);
746
+ function markFieldAsTouched(path) {
747
+ touchedFields.add(path);
709
748
  }
710
749
  /**
711
- * Sets or updates an error issue in the errors array, replacing any existing issue for the same path.
712
- * @param newIssue The issue to set.
750
+ * Marks every current leaf field as touched. Only paths present in
751
+ * `values` right now are affected — see the note on
752
+ * {@linkcode NotFormInstance.markAllFieldsAsTouched}.
753
+ * @internal
713
754
  */
714
- function setError(newIssue) {
715
- const newPath = newIssue.path?.map((element) => normalizeSegment(element)).join(".");
716
- const existingIndex = errors.findIndex((error) => {
717
- return error.path?.map((element) => normalizeSegment(element)).join(".") === newPath;
718
- });
719
- if (existingIndex === -1) errors.push(newIssue);
720
- else errors[existingIndex] = newIssue;
755
+ function markAllFieldsAsTouched() {
756
+ for (const path of deepKeys(values)) touchedFields.add(path);
721
757
  }
722
758
  /**
723
- * Sets multiple error issues in the errors array, replacing all existing issues.
724
- * @param newIssues The array of issues to set.
759
+ * Clears touched state for one field.
760
+ * @internal
761
+ * @param path Dot path.
725
762
  */
726
- function setErrors(newIssues) {
727
- errors.splice(0, errors.length, ...newIssues);
763
+ function unmarkFieldAsTouched(path) {
764
+ touchedFields.delete(path);
728
765
  }
729
- /** Clears all error issues from the errors array. */
730
- function clearErrors() {
731
- errors.length = 0;
766
+ /**
767
+ * Clears touched state for all fields.
768
+ * @internal
769
+ */
770
+ function unmarkAllFieldsAsTouched() {
771
+ touchedFields.clear();
732
772
  }
733
773
  /**
734
- * Retrieves all error issues associated with a specific field path.
735
- * @param path The field path to retrieve errors for.
736
- * @returns An array of error issues associated with the field path.
774
+ * Increments the in-flight validation count for each path.
775
+ * @internal
776
+ * @param paths Field paths.
737
777
  */
738
- function getFieldErrors(path) {
739
- const pathSegments = parsePath(path);
740
- return errors.filter((error) => isIssuePathEqual(error.path, pathSegments));
778
+ function markFieldsAsValidating(paths) {
779
+ for (const path of paths) {
780
+ validatingFieldCounts.set(path, (validatingFieldCounts.get(path) ?? 0) + 1);
781
+ validatingFields.add(path);
782
+ }
741
783
  }
742
784
  /**
743
- * Validates the entire form against the schema, updating errors and returning the result.
744
- *
745
- * Only the latest validation cycle is allowed to mutate form validation state.
746
- * @returns An object containing either `value` or `issues` based on validation outcome.
785
+ * Decrements in-flight counts and clears a path when the count hits zero.
786
+ * @internal
787
+ * @param paths Field paths.
788
+ */
789
+ function unmarkFieldsAsValidating(paths) {
790
+ for (const path of paths) {
791
+ const nextCount = (validatingFieldCounts.get(path) ?? 0) - 1;
792
+ if (nextCount <= 0) {
793
+ validatingFieldCounts.delete(path);
794
+ validatingFields.delete(path);
795
+ } else validatingFieldCounts.set(path, nextCount);
796
+ }
797
+ }
798
+ /**
799
+ * Validates the whole form, replacing all errors. Bumps `generation`; see
800
+ * {@linkcode NotFormInstance.validate} for the concurrency guarantee this gives.
801
+ * @returns Standard Schema result.
747
802
  */
748
803
  async function validate() {
749
- const cycle = beginValidation();
804
+ const cycle = ++generation;
805
+ const paths = [...deepKeys(values)];
806
+ markFieldsAsValidating(paths);
750
807
  try {
751
- const result = await runSchema();
752
- if (!isCurrentValidation(cycle)) return result;
808
+ const result = await executeSchemaValidation();
809
+ if (cycle !== generation) return result;
753
810
  if (result.issues) {
754
811
  const issues = [...result.issues];
755
- setErrors(issues);
812
+ replaceErrors(issues);
756
813
  return { issues };
757
814
  }
758
815
  clearErrors();
759
816
  return { value: result.value };
760
817
  } finally {
761
- endValidation();
818
+ unmarkFieldsAsValidating(paths);
762
819
  }
763
820
  }
764
821
  /**
765
- * Validates a specific field against the schema, updating errors and returning the result.
822
+ * Validates the form and writes issues only for `path`. `path` can be any
823
+ * granularity — see {@linkcode NotFormInstance.validateField}.
766
824
  *
767
- * Only the latest validation cycle is allowed to mutate form validation state.
768
- * @param path The field path to validate.
769
- * @returns An object containing either `value` or `issues` based on validation outcome.
825
+ * Dropped if a newer call for the same path or a newer whole-form generation starts.
826
+ * @param path Dot path.
827
+ * @returns Standard Schema result.
770
828
  */
771
829
  async function validateField(path) {
772
- const cycle = beginFieldValidation(path);
830
+ const cycle = (fieldValidationCycleMap.get(path) ?? 0) + 1;
831
+ fieldValidationCycleMap.set(path, cycle);
832
+ const startGeneration = generation;
833
+ markFieldsAsValidating([path]);
773
834
  try {
774
- const result = await runSchema();
775
- if (!isCurrentFieldValidation(path, cycle)) return result;
776
- const pathSegments = parsePath(path);
777
- const staleIndices = [];
778
- for (const [index, error] of errors.entries()) if (isIssuePathEqual(error.path, pathSegments)) staleIndices.push(index);
779
- for (let index = staleIndices.length - 1; index >= 0; index--) errors.splice(staleIndices[index], 1);
780
- if (result?.issues) {
781
- const fieldIssues = result.issues.filter((issue) => isIssuePathEqual(issue.path, pathSegments));
782
- if (fieldIssues.length > 0) {
783
- errors.push(...fieldIssues);
784
- return { issues: fieldIssues };
785
- }
786
- return { value: getProperty(values, path) };
787
- }
788
- return { value: getProperty(result.value, path) };
835
+ const result = await executeSchemaValidation();
836
+ if (generation !== startGeneration || fieldValidationCycleMap.get(path) !== cycle) return result;
837
+ const targetPath = parsePath(path);
838
+ for (let errorIndex = errors.length - 1; errorIndex >= 0; errorIndex--) if (areIssuePathsEqual(errors[errorIndex].path, targetPath)) errors.splice(errorIndex, 1);
839
+ const fieldIssues = (result.issues ?? []).filter((issue) => areIssuePathsEqual(issue.path, targetPath));
840
+ errors.push(...fieldIssues);
841
+ if (fieldIssues.length > 0) return { issues: fieldIssues };
842
+ return { value: getProperty(values, path) };
789
843
  } finally {
790
- endValidation();
844
+ unmarkFieldsAsValidating([path]);
791
845
  }
792
846
  }
793
847
  /**
794
- * Submits the form, validating it and executing the submit handler if provided.
795
- *
796
- * Duplicate submissions are ignored while a submission is already in progress.
797
- * @param event The submit event.
848
+ * Touches all fields, validates, then runs `onSubmit` when valid.
849
+ * @param event Optional submit event. `event.preventDefault()` is called
850
+ * immediately if `event` is given, before checking whether a submission
851
+ * is already in flight or whether validation passes.
798
852
  */
799
853
  async function submit(event) {
800
- event.preventDefault();
854
+ event?.preventDefault();
801
855
  if (isSubmitting.value) return;
856
+ markAllFieldsAsTouched();
857
+ syncAllDirtyStates();
858
+ const cycle = ++generation;
859
+ const paths = [...deepKeys(values)];
860
+ markFieldsAsValidating(paths);
802
861
  isSubmitting.value = true;
803
- const cycle = beginValidation();
804
862
  try {
805
- touchAllFields();
806
- dirtyAllFields();
807
- const result = await runSchema();
808
- if (!isCurrentValidation(cycle)) return;
863
+ const result = await executeSchemaValidation();
864
+ if (cycle !== generation) return;
809
865
  if (result.issues) {
810
- setErrors([...result.issues]);
866
+ replaceErrors([...result.issues]);
811
867
  return;
812
868
  }
813
869
  clearErrors();
814
- if (config.onSubmit) await config.onSubmit(result.value);
870
+ await config.onSubmit?.(result.value);
815
871
  } finally {
816
- endValidation();
872
+ unmarkFieldsAsValidating(paths);
817
873
  isSubmitting.value = false;
818
874
  }
819
875
  }
820
876
  /**
821
- * Resets the form to its initial state.
822
- * @param newValues The new values to reset the form with.
823
- * @param newErrors The new errors to reset the form with.
877
+ * Restores values and errors to the baseline. Optional arguments become
878
+ * the new baseline and fully replace the previous one — see
879
+ * {@linkcode NotFormInstance.reset} for the "replace, not merge" detail.
880
+ * @param nextValues New baseline values.
881
+ * @param nextErrors New baseline issues.
824
882
  */
825
- function reset(newValues, newErrors) {
826
- validationCycle++;
827
- if (newValues) initialValues = klona(newValues);
828
- if (newErrors) initialErrors = klona(newErrors);
829
- const freshValues = klona(initialValues);
830
- for (const key of Object.keys(values)) if (!hasProperty(freshValues, key)) deleteProperty(values, key);
831
- for (const key of Object.keys(freshValues)) setProperty(values, key, getProperty(freshValues, key));
832
- errors.splice(0, errors.length, ...klona(initialErrors));
833
- touchedFields.clear();
834
- dirtyFields.clear();
883
+ function reset(nextValues, nextErrors) {
884
+ generation++;
885
+ fieldValidationCycleMap.clear();
886
+ validatingFieldCounts.clear();
887
+ validatingFields.clear();
888
+ if (nextValues) {
889
+ const freshValues = klona(nextValues);
890
+ for (const key of Object.keys(initialValues)) deleteProperty(initialValues, key);
891
+ Object.assign(initialValues, freshValues);
892
+ }
893
+ if (nextErrors) initialErrors.splice(0, initialErrors.length, ...klona(nextErrors));
894
+ for (const key of Object.keys(values)) deleteProperty(values, key);
895
+ Object.assign(values, klona(initialValues));
896
+ replaceErrors(klona(initialErrors));
897
+ unmarkAllFieldsAsTouched();
898
+ unmarkAllFieldsAsDirty();
835
899
  }
836
- return markRaw({
837
- get initialErrors() {
838
- return initialErrors;
839
- },
840
- get initialValues() {
841
- return initialValues;
842
- },
843
- validateOn,
844
- validationMode,
845
- setValue,
846
- values,
847
- isTouched,
848
- touchedFields,
849
- touchField,
850
- dirtyField,
851
- dirtyFields,
852
- isDirty,
900
+ return reactive({
853
901
  clearErrors,
902
+ dirtyFields,
854
903
  errors,
855
- errorsMap,
856
904
  getFieldErrors,
857
- setError,
858
- setErrors,
905
+ isDirty,
906
+ isSubmitting,
907
+ isTouched,
859
908
  isValid,
860
909
  isValidating,
910
+ markAllFieldsAsDirty,
911
+ markAllFieldsAsTouched,
912
+ markFieldAsDirty,
913
+ markFieldAsTouched,
914
+ replaceErrors,
915
+ reset,
916
+ setError,
917
+ setValue,
918
+ submit,
919
+ syncAllDirtyStates,
920
+ syncDirtyState,
921
+ touchedFields,
922
+ unmarkAllFieldsAsDirty,
923
+ unmarkAllFieldsAsTouched,
924
+ unmarkFieldAsDirty,
925
+ unmarkFieldAsTouched,
861
926
  validate,
862
927
  validateField,
863
- isSubmitting,
864
- submit,
865
- reset
928
+ validatingFields,
929
+ values
866
930
  });
867
931
  }
868
932
  //#endregion
933
+ //#region src/composables/use-not-form.ts
934
+ /**
935
+ * Creates a form instance and returns the public {@linkcode NotFormAPI}.
936
+ * @template TSchema The form schema.
937
+ * @param config Schema, initial values/errors, and submit handler.
938
+ * @returns Public form API. Child components still inject the full instance via `<NotForm>`.
939
+ */
940
+ function useNotForm(config) {
941
+ return createNotFormInstance(config);
942
+ }
943
+ //#endregion
869
944
  export { not_array_field_default as NotArrayField, not_field_default as NotField, not_form_default as NotForm, not_message_default as NotMessage, useNotForm };