notform 3.0.1 → 4.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -186
- package/dist/index.d.ts +459 -475
- package/dist/index.js +1103 -790
- package/package.json +19 -22
package/dist/index.js
CHANGED
|
@@ -1,945 +1,1258 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
*
|
|
8
|
-
* @
|
|
9
|
-
* @
|
|
10
|
-
* @
|
|
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
|
|
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
|
-
*
|
|
18
|
-
* @
|
|
19
|
-
* @param
|
|
20
|
-
* @
|
|
21
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
*
|
|
32
|
-
* nested underneath it, at any depth.
|
|
160
|
+
* Checks whether two path steps are the same key.
|
|
33
161
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
*
|
|
61
|
-
* @
|
|
62
|
-
* @param
|
|
63
|
-
* @param
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
*
|
|
79
|
-
* @
|
|
80
|
-
* @
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* @param
|
|
115
|
-
* @param
|
|
116
|
-
* @
|
|
117
|
-
*/
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
*
|
|
150
|
-
* @
|
|
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
|
-
|
|
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
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* @param
|
|
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
|
|
160
|
-
|
|
295
|
+
function isFieldAffectedByScope(runScope, fieldSegments) {
|
|
296
|
+
if (runScope.type === "form") return true;
|
|
297
|
+
return isPathInside(runScope.segments, fieldSegments);
|
|
161
298
|
}
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
*
|
|
169
|
-
* @
|
|
170
|
-
* @
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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
|
-
* `
|
|
194
|
-
* the
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
/**
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
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
|
|
473
|
+
return host.isFieldTouched(id);
|
|
239
474
|
});
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
482
|
+
return host.isFieldValidating(path.value);
|
|
247
483
|
});
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
*
|
|
257
|
-
* @param
|
|
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
|
|
260
|
-
|
|
261
|
-
if (
|
|
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
|
-
*
|
|
265
|
-
* @returns The
|
|
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
|
|
268
|
-
const
|
|
269
|
-
|
|
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
|
-
*
|
|
276
|
-
* @
|
|
277
|
-
* @
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
*
|
|
289
|
-
* @param
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
*
|
|
297
|
-
*
|
|
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
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
340
|
-
*
|
|
341
|
-
* @
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
*
|
|
356
|
-
*
|
|
357
|
-
* @
|
|
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
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
*
|
|
474
|
-
*
|
|
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
|
|
478
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
897
|
+
return {
|
|
898
|
+
activeRuns: readonly(activeRuns),
|
|
899
|
+
finishRun,
|
|
900
|
+
markAllRunsStale,
|
|
901
|
+
startRun
|
|
484
902
|
};
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
490
|
-
|
|
936
|
+
const isTouched = computed(() => {
|
|
937
|
+
return isSubmitted.value || touchedFields.size > 0;
|
|
491
938
|
});
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
function
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
/**
|
|
603
|
-
|
|
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
|
-
*
|
|
631
|
-
*
|
|
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
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const validatingFieldCounts = /* @__PURE__ */ new Map();
|
|
977
|
+
let latestSubmitAttemptId = 0;
|
|
978
|
+
const isValidating = computed(() => {
|
|
979
|
+
return validationRuns.activeRuns.size > 0;
|
|
980
|
+
});
|
|
639
981
|
/**
|
|
640
|
-
*
|
|
641
|
-
*
|
|
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
|
|
646
|
-
|
|
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
|
-
*
|
|
650
|
-
*
|
|
651
|
-
* @param
|
|
652
|
-
* @param
|
|
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
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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 =
|
|
690
|
-
return errors.filter((error) =>
|
|
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
|
-
*
|
|
761
|
-
* @
|
|
762
|
-
* @param
|
|
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
|
|
765
|
-
|
|
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
|
-
|
|
769
|
-
|
|
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
|
-
*
|
|
776
|
-
* @
|
|
777
|
-
* @param
|
|
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
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
*
|
|
787
|
-
*
|
|
788
|
-
* @param
|
|
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
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
|
801
|
-
*
|
|
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
|
|
806
|
-
|
|
807
|
-
|
|
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
|
|
824
|
-
* granularity — see {@linkcode NotFormInstance.validateField}.
|
|
1106
|
+
* Validates the whole form and, if valid, calls `config.onSubmit` with the parsed data.
|
|
825
1107
|
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
937
|
-
* @
|
|
938
|
-
* @
|
|
939
|
-
|
|
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
|
|
942
|
-
return
|
|
1255
|
+
function useForm(config) {
|
|
1256
|
+
return createForm(config);
|
|
943
1257
|
}
|
|
944
|
-
|
|
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 };
|