notform 2.0.0-alpha.2 → 2.0.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +187 -2837
- package/dist/index.js +319 -22
- package/package.json +2 -5
package/dist/index.js
CHANGED
|
@@ -1,36 +1,268 @@
|
|
|
1
|
-
import { computed, createElementBlock, defineComponent, guardReactiveProps, inject, normalizeProps, openBlock, provide, reactive, renderSlot, unref, useAttrs } from "vue";
|
|
1
|
+
import { computed, createElementBlock, defineComponent, guardReactiveProps, inject, markRaw, nextTick, normalizeProps, onMounted, openBlock, provide, reactive, ref, renderSlot, toValue, unref, useAttrs } from "vue";
|
|
2
|
+
import { klona } from "klona/full";
|
|
3
|
+
import { dequal } from "dequal";
|
|
4
|
+
import { deepKeys, deleteProperty, getProperty, hasProperty, parsePath, setProperty } from "dot-prop";
|
|
5
|
+
//#region src/utils/form-utils.ts
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
function normalizeSegment(segment) {
|
|
12
|
+
if (typeof segment === "object" && segment !== null && "key" in segment) return segment.key;
|
|
13
|
+
return segment;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Checks if a validation issue path matches a target field path.
|
|
17
|
+
* @param issuePath The path array from the validation issue.
|
|
18
|
+
* @param targetPath The normalized path to compare against.
|
|
19
|
+
* @returns True if the paths are equivalent.
|
|
20
|
+
*/
|
|
21
|
+
function isIssuePathEqual(issuePath, targetPath) {
|
|
22
|
+
if (!issuePath) return false;
|
|
23
|
+
if (issuePath.length !== targetPath.length) return false;
|
|
24
|
+
return issuePath.every((segment, index) => {
|
|
25
|
+
const normalizedSegment = normalizeSegment(segment);
|
|
26
|
+
const targetSegment = targetPath[index];
|
|
27
|
+
if (typeof normalizedSegment === "number" || typeof targetSegment === "number") return Number(normalizedSegment) === Number(targetSegment);
|
|
28
|
+
return normalizedSegment === targetSegment;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/composables/use-not-form.ts
|
|
33
|
+
function useNotForm(config) {
|
|
34
|
+
/** Runs the schema against the current values and returns the raw result. */
|
|
35
|
+
const runSchema = () => {
|
|
36
|
+
return toValue(config.schema)["~standard"].validate(values);
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Marks all current leaf paths as touched.
|
|
40
|
+
* Called internally on submit so all field errors surface at once.
|
|
41
|
+
*/
|
|
42
|
+
const touchAllFields = () => {
|
|
43
|
+
deepKeys(values).forEach((path) => touchedFields.add(path));
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Marks all current leaf paths as dirty.
|
|
47
|
+
* Called internally on submit so required-field errors are not suppressed.
|
|
48
|
+
*/
|
|
49
|
+
const dirtyAllFields = () => {
|
|
50
|
+
deepKeys(values).forEach((path) => dirtyFields.add(path));
|
|
51
|
+
};
|
|
52
|
+
/** Removes a path from the dirty set without exposing the operation publicly. */
|
|
53
|
+
const unDirtyField = (path) => {
|
|
54
|
+
dirtyFields.delete(path);
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Mutable so `reset()` can replace the reference when new values are provided.
|
|
58
|
+
* Always deep-cloned to prevent external mutation from affecting the baseline.
|
|
59
|
+
*/
|
|
60
|
+
let initialValues = klona(config.initialValues ?? {});
|
|
61
|
+
let initialErrors = klona(config.initialErrors ?? []);
|
|
62
|
+
const validateOn = {
|
|
63
|
+
onBlur: config.validateOn?.onBlur ?? true,
|
|
64
|
+
onChange: config.validateOn?.onChange ?? true,
|
|
65
|
+
onInput: config.validateOn?.onInput ?? true,
|
|
66
|
+
onMount: config.validateOn?.onMount ?? false,
|
|
67
|
+
onFocus: config.validateOn?.onFocus ?? false
|
|
68
|
+
};
|
|
69
|
+
const validationMode = {
|
|
70
|
+
eager: config.validationMode?.eager ?? true,
|
|
71
|
+
lazy: config.validationMode?.lazy ?? false
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Deeply reactive object — access directly as `form.values.email`.
|
|
75
|
+
* Using `reactive()` instead of `ref()` keeps behaviour consistent
|
|
76
|
+
* across components, composables, and Pinia stores (which auto-unwrap refs).
|
|
77
|
+
*/
|
|
78
|
+
const values = reactive(klona(initialValues));
|
|
79
|
+
/**
|
|
80
|
+
* Reactive array mutated in-place to preserve reactivity.
|
|
81
|
+
* Using `reactive()` instead of `ref()` prevents Pinia from unwrapping
|
|
82
|
+
* the array and losing its reactive proxy.
|
|
83
|
+
*/
|
|
84
|
+
const errors = reactive([...initialErrors]);
|
|
85
|
+
const isSubmitting = ref(false);
|
|
86
|
+
const isValidating = ref(false);
|
|
87
|
+
/**
|
|
88
|
+
* Reactive Sets using `reactive()` for the same Pinia-compatibility reason
|
|
89
|
+
* as `errors` above — `ref(new Set())` would be unwrapped to a plain Set.
|
|
90
|
+
*/
|
|
91
|
+
const touchedFields = reactive(/* @__PURE__ */ new Set());
|
|
92
|
+
const dirtyFields = reactive(/* @__PURE__ */ new Set());
|
|
93
|
+
const isValid = computed(() => errors.length === 0);
|
|
94
|
+
const isDirty = computed(() => dirtyFields.size > 0);
|
|
95
|
+
const isTouched = computed(() => touchedFields.size > 0);
|
|
96
|
+
const errorsMap = computed(() => {
|
|
97
|
+
return errors.reduce((errorsByPath, issue) => {
|
|
98
|
+
if (!issue.path) return errorsByPath;
|
|
99
|
+
const path = issue.path.map(normalizeSegment).join(".");
|
|
100
|
+
if (path && !errorsByPath[path]) errorsByPath[path] = issue.message;
|
|
101
|
+
return errorsByPath;
|
|
102
|
+
}, {});
|
|
103
|
+
});
|
|
104
|
+
const setValue = (path, value) => {
|
|
105
|
+
setProperty(values, path, value);
|
|
106
|
+
if (dequal(value, getProperty(initialValues, path))) unDirtyField(path);
|
|
107
|
+
else dirtyField(path);
|
|
108
|
+
};
|
|
109
|
+
const touchField = (path) => {
|
|
110
|
+
touchedFields.add(path);
|
|
111
|
+
};
|
|
112
|
+
const dirtyField = (path) => {
|
|
113
|
+
dirtyFields.add(path);
|
|
114
|
+
};
|
|
115
|
+
const setError = (newIssue) => {
|
|
116
|
+
const newPath = newIssue.path?.map(normalizeSegment).join(".");
|
|
117
|
+
const existingIndex = errors.findIndex((error) => error.path?.map(normalizeSegment).join(".") === newPath);
|
|
118
|
+
if (existingIndex !== -1) errors.splice(existingIndex, 1, newIssue);
|
|
119
|
+
else errors.push(newIssue);
|
|
120
|
+
};
|
|
121
|
+
const setErrors = (newIssues) => {
|
|
122
|
+
errors.splice(0, errors.length, ...newIssues);
|
|
123
|
+
};
|
|
124
|
+
const clearErrors = () => {
|
|
125
|
+
errors.splice(0, errors.length);
|
|
126
|
+
};
|
|
127
|
+
const getFieldErrors = (path) => {
|
|
128
|
+
const pathSegments = parsePath(path);
|
|
129
|
+
return errors.filter((error) => isIssuePathEqual(error.path, pathSegments));
|
|
130
|
+
};
|
|
131
|
+
const validate = async () => {
|
|
132
|
+
isValidating.value = true;
|
|
133
|
+
try {
|
|
134
|
+
const result = await runSchema();
|
|
135
|
+
if (result?.issues) {
|
|
136
|
+
setErrors([...result.issues]);
|
|
137
|
+
return { issues: result.issues };
|
|
138
|
+
}
|
|
139
|
+
clearErrors();
|
|
140
|
+
return { value: result.value };
|
|
141
|
+
} finally {
|
|
142
|
+
isValidating.value = false;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const validateField = async (path) => {
|
|
146
|
+
isValidating.value = true;
|
|
147
|
+
try {
|
|
148
|
+
const result = await runSchema();
|
|
149
|
+
const pathSegments = parsePath(path);
|
|
150
|
+
const staleIndices = errors.reduce((indices, error, index) => {
|
|
151
|
+
if (isIssuePathEqual(error.path, pathSegments)) indices.push(index);
|
|
152
|
+
return indices;
|
|
153
|
+
}, []);
|
|
154
|
+
for (let index = staleIndices.length - 1; index >= 0; index--) errors.splice(staleIndices[index], 1);
|
|
155
|
+
if (result?.issues) {
|
|
156
|
+
const fieldIssues = result.issues.filter((issue) => isIssuePathEqual(issue.path, pathSegments));
|
|
157
|
+
if (fieldIssues.length > 0) {
|
|
158
|
+
errors.push(...fieldIssues);
|
|
159
|
+
return { issues: fieldIssues };
|
|
160
|
+
}
|
|
161
|
+
return { value: getProperty(values, path) };
|
|
162
|
+
}
|
|
163
|
+
return { value: getProperty(result.value, path) };
|
|
164
|
+
} finally {
|
|
165
|
+
isValidating.value = false;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const submit = async (event) => {
|
|
169
|
+
isSubmitting.value = true;
|
|
170
|
+
try {
|
|
171
|
+
touchAllFields();
|
|
172
|
+
dirtyAllFields();
|
|
173
|
+
const result = await validate();
|
|
174
|
+
if (result?.issues) {
|
|
175
|
+
event.preventDefault();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (config.onSubmit) {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
await config.onSubmit(result.value);
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
} finally {
|
|
185
|
+
isSubmitting.value = false;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const reset = (newValues, newErrors) => {
|
|
189
|
+
if (newValues) initialValues = klona(newValues);
|
|
190
|
+
if (newErrors) initialErrors = klona(newErrors);
|
|
191
|
+
const freshValues = klona(initialValues);
|
|
192
|
+
deepKeys(values).forEach((path) => {
|
|
193
|
+
if (!hasProperty(freshValues, path)) deleteProperty(values, path);
|
|
194
|
+
});
|
|
195
|
+
deepKeys(freshValues).forEach((path) => {
|
|
196
|
+
setProperty(values, path, getProperty(freshValues, path));
|
|
197
|
+
});
|
|
198
|
+
errors.splice(0, errors.length, ...klona(initialErrors));
|
|
199
|
+
touchedFields.clear();
|
|
200
|
+
dirtyFields.clear();
|
|
201
|
+
};
|
|
202
|
+
return markRaw({
|
|
203
|
+
initialValues,
|
|
204
|
+
initialErrors,
|
|
205
|
+
validateOn,
|
|
206
|
+
validationMode,
|
|
207
|
+
values,
|
|
208
|
+
setValue,
|
|
209
|
+
touchedFields,
|
|
210
|
+
touchField,
|
|
211
|
+
isTouched,
|
|
212
|
+
dirtyFields,
|
|
213
|
+
dirtyField,
|
|
214
|
+
isDirty,
|
|
215
|
+
errors,
|
|
216
|
+
errorsMap,
|
|
217
|
+
setError,
|
|
218
|
+
setErrors,
|
|
219
|
+
clearErrors,
|
|
220
|
+
getFieldErrors,
|
|
221
|
+
isValidating,
|
|
222
|
+
validate,
|
|
223
|
+
validateField,
|
|
224
|
+
isValid,
|
|
225
|
+
isSubmitting,
|
|
226
|
+
submit,
|
|
227
|
+
reset
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
//#endregion
|
|
2
231
|
//#region src/utils/instance-utils.ts
|
|
3
|
-
/**
|
|
232
|
+
/** The injection key for the NotForm instance. */
|
|
4
233
|
const NOT_FORM_INSTANCE_KEY = Symbol("notform:instance");
|
|
5
234
|
/**
|
|
6
|
-
* Provides
|
|
7
|
-
* @param instance The
|
|
235
|
+
* Provides the NotForm instance to the component tree.
|
|
236
|
+
* @param instance The NotForm instance to provide.
|
|
8
237
|
*/
|
|
9
238
|
function provideNotFormInstance(instance) {
|
|
10
239
|
provide(NOT_FORM_INSTANCE_KEY, instance);
|
|
11
240
|
}
|
|
12
241
|
/**
|
|
13
|
-
*
|
|
14
|
-
* @param explicitInstance Optional instance
|
|
15
|
-
* @
|
|
242
|
+
* Retrieves the NotForm instance from the component tree.
|
|
243
|
+
* @param explicitInstance Optional explicit instance to use.
|
|
244
|
+
* @returns The NotForm instance.
|
|
16
245
|
*/
|
|
17
246
|
function useNotFormInstance(explicitInstance) {
|
|
18
|
-
const injected = inject(NOT_FORM_INSTANCE_KEY);
|
|
247
|
+
const injected = inject(NOT_FORM_INSTANCE_KEY, void 0);
|
|
19
248
|
const instance = explicitInstance ?? injected;
|
|
20
|
-
if (!instance) throw new Error(
|
|
249
|
+
if (!instance) throw new Error(`
|
|
250
|
+
[NotForm] No form instance found.
|
|
251
|
+
Wrap with <NotForm :form="..."> or pass :form directly to the field.
|
|
252
|
+
`);
|
|
21
253
|
return instance;
|
|
22
254
|
}
|
|
23
255
|
//#endregion
|
|
24
256
|
//#region src/components/not-form.vue
|
|
25
257
|
var not_form_default = /* @__PURE__ */ defineComponent({
|
|
26
258
|
__name: "not-form",
|
|
27
|
-
props: {
|
|
28
|
-
type:
|
|
259
|
+
props: { form: {
|
|
260
|
+
type: null,
|
|
29
261
|
required: true
|
|
30
262
|
} },
|
|
31
263
|
setup(__props) {
|
|
32
264
|
const attributes = useAttrs();
|
|
33
|
-
provideNotFormInstance(__props.
|
|
265
|
+
provideNotFormInstance(__props.form);
|
|
34
266
|
return (_ctx, _cache) => {
|
|
35
267
|
return openBlock(), createElementBlock("form", normalizeProps(guardReactiveProps(unref(attributes))), [renderSlot(_ctx.$slots, "default")], 16);
|
|
36
268
|
};
|
|
@@ -47,25 +279,90 @@ var not_field_default = /* @__PURE__ */ defineComponent({
|
|
|
47
279
|
required: true
|
|
48
280
|
},
|
|
49
281
|
form: {
|
|
282
|
+
type: null,
|
|
283
|
+
required: false
|
|
284
|
+
},
|
|
285
|
+
validateOn: {
|
|
50
286
|
type: Object,
|
|
51
287
|
required: false
|
|
52
288
|
}
|
|
53
289
|
},
|
|
54
290
|
setup(__props) {
|
|
55
291
|
const props = __props;
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
292
|
+
const form = useNotFormInstance(props.form);
|
|
293
|
+
const validateOn = computed(() => ({
|
|
294
|
+
...form.validateOn,
|
|
295
|
+
...props.validateOn
|
|
296
|
+
}));
|
|
297
|
+
const isValidating = ref(false);
|
|
298
|
+
const value = computed(() => getProperty(form.values, props.path));
|
|
299
|
+
const errors = computed(() => form.getFieldErrors(props.path));
|
|
300
|
+
const isValid = computed(() => errors.value.length === 0);
|
|
301
|
+
const isTouched = computed(() => form.touchedFields.has(props.path));
|
|
302
|
+
const isDirty = computed(() => form.dirtyFields.has(props.path));
|
|
303
|
+
/**
|
|
304
|
+
* Syncs dirty state on input or change.
|
|
305
|
+
* Reads the already-updated value (v-model writes before the event fires)
|
|
306
|
+
* and compares it against the field's initial value.
|
|
307
|
+
*/
|
|
308
|
+
const updateDirty = () => {
|
|
309
|
+
if (dequal(value.value, getProperty(form.initialValues, props.path))) form.dirtyFields.delete(props.path);
|
|
310
|
+
else form.dirtyField(props.path);
|
|
311
|
+
};
|
|
312
|
+
const validate = async () => {
|
|
313
|
+
isValidating.value = true;
|
|
314
|
+
try {
|
|
315
|
+
return await form.validateField(props.path);
|
|
316
|
+
} finally {
|
|
317
|
+
isValidating.value = false;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
const onBlur = () => {
|
|
321
|
+
form.touchField(props.path);
|
|
322
|
+
if (validateOn.value.onBlur) validate();
|
|
323
|
+
};
|
|
324
|
+
const onInput = () => {
|
|
325
|
+
updateDirty();
|
|
326
|
+
if (!validateOn.value.onInput) return;
|
|
327
|
+
if (form.validationMode.eager && errors.value.length > 0) validate();
|
|
328
|
+
};
|
|
329
|
+
const onChange = () => {
|
|
330
|
+
updateDirty();
|
|
331
|
+
if (!validateOn.value.onChange) return;
|
|
332
|
+
if (form.validationMode.eager && errors.value.length > 0) validate();
|
|
333
|
+
};
|
|
334
|
+
const onFocus = () => {
|
|
335
|
+
if (validateOn.value.onFocus) validate();
|
|
336
|
+
};
|
|
337
|
+
const events = computed(() => ({
|
|
338
|
+
onBlur,
|
|
339
|
+
onInput,
|
|
340
|
+
onChange,
|
|
341
|
+
onFocus
|
|
342
|
+
}));
|
|
343
|
+
onMounted(async () => {
|
|
344
|
+
await nextTick();
|
|
345
|
+
if (validateOn.value.onMount) validate();
|
|
64
346
|
});
|
|
347
|
+
const slotProps = computed(() => ({
|
|
348
|
+
path: props.path,
|
|
349
|
+
value: value.value,
|
|
350
|
+
errors: errors.value,
|
|
351
|
+
isValid: isValid.value,
|
|
352
|
+
isTouched: isTouched.value,
|
|
353
|
+
isDirty: isDirty.value,
|
|
354
|
+
isValidating: isValidating.value,
|
|
355
|
+
validate,
|
|
356
|
+
events: events.value,
|
|
357
|
+
onBlur,
|
|
358
|
+
onInput,
|
|
359
|
+
onChange,
|
|
360
|
+
onFocus
|
|
361
|
+
}));
|
|
65
362
|
return (_ctx, _cache) => {
|
|
66
|
-
return renderSlot(_ctx.$slots, "default", normalizeProps(guardReactiveProps(
|
|
363
|
+
return renderSlot(_ctx.$slots, "default", normalizeProps(guardReactiveProps(slotProps.value)));
|
|
67
364
|
};
|
|
68
365
|
}
|
|
69
366
|
});
|
|
70
367
|
//#endregion
|
|
71
|
-
export { not_field_default as NotField, not_form_default as NotForm };
|
|
368
|
+
export { not_field_default as NotField, not_form_default as NotForm, useNotForm };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "notform",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "Vue Forms Without the Friction",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "^25.5.0",
|
|
34
34
|
"@vitejs/plugin-vue": "^6.0.5",
|
|
35
|
+
"@vitest/ui": "^4.1.4",
|
|
35
36
|
"@vue/test-utils": "^2.4.6",
|
|
36
37
|
"happy-dom": "^20.8.9",
|
|
37
38
|
"tsdown": "^0.21.7",
|
|
@@ -58,10 +59,6 @@
|
|
|
58
59
|
"engines": {
|
|
59
60
|
"node": ">=24.13.0"
|
|
60
61
|
},
|
|
61
|
-
"inlinedDependencies": {
|
|
62
|
-
"tagged-tag": "1.0.0",
|
|
63
|
-
"type-fest": "5.5.0"
|
|
64
|
-
},
|
|
65
62
|
"scripts": {
|
|
66
63
|
"build": "tsdown",
|
|
67
64
|
"dev": "tsdown --watch",
|