notform 2.0.0-alpha.3 → 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 +178 -2861
- package/dist/index.js +159 -119
- package/package.json +2 -5
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { computed, createElementBlock, defineComponent, guardReactiveProps, inject, nextTick, normalizeProps, onMounted, openBlock, provide, reactive, ref, renderSlot, toValue, 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
2
|
import { klona } from "klona/full";
|
|
3
3
|
import { dequal } from "dequal";
|
|
4
|
-
import { deepKeys, getProperty, parsePath, setProperty } from "dot-prop";
|
|
4
|
+
import { deepKeys, deleteProperty, getProperty, hasProperty, parsePath, setProperty } from "dot-prop";
|
|
5
5
|
//#region src/utils/form-utils.ts
|
|
6
6
|
/**
|
|
7
7
|
* Normalizes a validation path segment into a standard property key.
|
|
@@ -31,6 +31,32 @@ function isIssuePathEqual(issuePath, targetPath) {
|
|
|
31
31
|
//#endregion
|
|
32
32
|
//#region src/composables/use-not-form.ts
|
|
33
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
|
+
*/
|
|
34
60
|
let initialValues = klona(config.initialValues ?? {});
|
|
35
61
|
let initialErrors = klona(config.initialErrors ?? []);
|
|
36
62
|
const validateOn = {
|
|
@@ -40,74 +66,72 @@ function useNotForm(config) {
|
|
|
40
66
|
onMount: config.validateOn?.onMount ?? false,
|
|
41
67
|
onFocus: config.validateOn?.onFocus ?? false
|
|
42
68
|
};
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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]);
|
|
47
85
|
const isSubmitting = ref(false);
|
|
48
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);
|
|
49
96
|
const errorsMap = computed(() => {
|
|
50
|
-
return errors.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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;
|
|
54
102
|
}, {});
|
|
55
103
|
});
|
|
56
|
-
const isDirty = computed(() => dirtyFields.value.size > 0);
|
|
57
|
-
const isTouched = computed(() => touchedFields.value.size > 0);
|
|
58
|
-
const isValid = computed(() => errors.value.length === 0);
|
|
59
|
-
const touchField = (path) => {
|
|
60
|
-
touchedFields.value.add(path);
|
|
61
|
-
};
|
|
62
|
-
const unTouchField = (path) => {
|
|
63
|
-
touchedFields.value.delete(path);
|
|
64
|
-
};
|
|
65
|
-
const touchAllFields = () => {
|
|
66
|
-
deepKeys(values.value).forEach((path) => touchedFields.value.add(path));
|
|
67
|
-
};
|
|
68
|
-
const unTouchAllFields = () => {
|
|
69
|
-
touchedFields.value.clear();
|
|
70
|
-
};
|
|
71
|
-
const dirtyField = (path) => {
|
|
72
|
-
dirtyFields.value.add(path);
|
|
73
|
-
};
|
|
74
|
-
const unDirtyField = (path) => {
|
|
75
|
-
dirtyFields.value.delete(path);
|
|
76
|
-
};
|
|
77
|
-
const dirtyAllFields = () => {
|
|
78
|
-
deepKeys(values.value).forEach((path) => dirtyFields.value.add(path));
|
|
79
|
-
};
|
|
80
|
-
const unDirtyAllFields = () => {
|
|
81
|
-
dirtyFields.value.clear();
|
|
82
|
-
};
|
|
83
104
|
const setValue = (path, value) => {
|
|
84
|
-
setProperty(values
|
|
85
|
-
touchField(path);
|
|
105
|
+
setProperty(values, path, value);
|
|
86
106
|
if (dequal(value, getProperty(initialValues, path))) unDirtyField(path);
|
|
87
107
|
else dirtyField(path);
|
|
88
|
-
if (validateOn.onChange) validateField(path);
|
|
89
108
|
};
|
|
90
|
-
const
|
|
91
|
-
|
|
109
|
+
const touchField = (path) => {
|
|
110
|
+
touchedFields.add(path);
|
|
111
|
+
};
|
|
112
|
+
const dirtyField = (path) => {
|
|
113
|
+
dirtyFields.add(path);
|
|
92
114
|
};
|
|
93
115
|
const setError = (newIssue) => {
|
|
94
|
-
const newPath = newIssue.path?.join(".");
|
|
95
|
-
|
|
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);
|
|
96
120
|
};
|
|
97
121
|
const setErrors = (newIssues) => {
|
|
98
|
-
errors.
|
|
122
|
+
errors.splice(0, errors.length, ...newIssues);
|
|
99
123
|
};
|
|
100
124
|
const clearErrors = () => {
|
|
101
|
-
errors.
|
|
125
|
+
errors.splice(0, errors.length);
|
|
102
126
|
};
|
|
103
127
|
const getFieldErrors = (path) => {
|
|
104
|
-
const
|
|
105
|
-
return errors.
|
|
128
|
+
const pathSegments = parsePath(path);
|
|
129
|
+
return errors.filter((error) => isIssuePathEqual(error.path, pathSegments));
|
|
106
130
|
};
|
|
107
131
|
const validate = async () => {
|
|
108
132
|
isValidating.value = true;
|
|
109
133
|
try {
|
|
110
|
-
const result = await
|
|
134
|
+
const result = await runSchema();
|
|
111
135
|
if (result?.issues) {
|
|
112
136
|
setErrors([...result.issues]);
|
|
113
137
|
return { issues: result.issues };
|
|
@@ -121,16 +145,20 @@ function useNotForm(config) {
|
|
|
121
145
|
const validateField = async (path) => {
|
|
122
146
|
isValidating.value = true;
|
|
123
147
|
try {
|
|
124
|
-
const result = await
|
|
125
|
-
const
|
|
126
|
-
|
|
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);
|
|
127
155
|
if (result?.issues) {
|
|
128
|
-
const fieldIssues = result.issues.filter((issue) => isIssuePathEqual(issue.path,
|
|
156
|
+
const fieldIssues = result.issues.filter((issue) => isIssuePathEqual(issue.path, pathSegments));
|
|
129
157
|
if (fieldIssues.length > 0) {
|
|
130
|
-
errors.
|
|
158
|
+
errors.push(...fieldIssues);
|
|
131
159
|
return { issues: fieldIssues };
|
|
132
160
|
}
|
|
133
|
-
return { value: getProperty(values
|
|
161
|
+
return { value: getProperty(values, path) };
|
|
134
162
|
}
|
|
135
163
|
return { value: getProperty(result.value, path) };
|
|
136
164
|
} finally {
|
|
@@ -160,29 +188,29 @@ function useNotForm(config) {
|
|
|
160
188
|
const reset = (newValues, newErrors) => {
|
|
161
189
|
if (newValues) initialValues = klona(newValues);
|
|
162
190
|
if (newErrors) initialErrors = klona(newErrors);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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();
|
|
167
201
|
};
|
|
168
|
-
|
|
202
|
+
return markRaw({
|
|
169
203
|
initialValues,
|
|
170
204
|
initialErrors,
|
|
171
205
|
validateOn,
|
|
206
|
+
validationMode,
|
|
172
207
|
values,
|
|
173
208
|
setValue,
|
|
174
|
-
setValues,
|
|
175
209
|
touchedFields,
|
|
176
210
|
touchField,
|
|
177
|
-
unTouchField,
|
|
178
|
-
touchAllFields,
|
|
179
|
-
unTouchAllFields,
|
|
180
211
|
isTouched,
|
|
181
212
|
dirtyFields,
|
|
182
213
|
dirtyField,
|
|
183
|
-
unDirtyField,
|
|
184
|
-
dirtyAllFields,
|
|
185
|
-
unDirtyAllFields,
|
|
186
214
|
isDirty,
|
|
187
215
|
errors,
|
|
188
216
|
errorsMap,
|
|
@@ -197,43 +225,44 @@ function useNotForm(config) {
|
|
|
197
225
|
isSubmitting,
|
|
198
226
|
submit,
|
|
199
227
|
reset
|
|
200
|
-
};
|
|
201
|
-
resolvedInstance.instance = resolvedInstance;
|
|
202
|
-
return resolvedInstance;
|
|
228
|
+
});
|
|
203
229
|
}
|
|
204
230
|
//#endregion
|
|
205
231
|
//#region src/utils/instance-utils.ts
|
|
206
|
-
/**
|
|
232
|
+
/** The injection key for the NotForm instance. */
|
|
207
233
|
const NOT_FORM_INSTANCE_KEY = Symbol("notform:instance");
|
|
208
234
|
/**
|
|
209
|
-
* Provides
|
|
210
|
-
* @param instance The
|
|
235
|
+
* Provides the NotForm instance to the component tree.
|
|
236
|
+
* @param instance The NotForm instance to provide.
|
|
211
237
|
*/
|
|
212
238
|
function provideNotFormInstance(instance) {
|
|
213
239
|
provide(NOT_FORM_INSTANCE_KEY, instance);
|
|
214
240
|
}
|
|
215
241
|
/**
|
|
216
|
-
*
|
|
217
|
-
* @param explicitInstance Optional instance
|
|
218
|
-
* @
|
|
242
|
+
* Retrieves the NotForm instance from the component tree.
|
|
243
|
+
* @param explicitInstance Optional explicit instance to use.
|
|
244
|
+
* @returns The NotForm instance.
|
|
219
245
|
*/
|
|
220
246
|
function useNotFormInstance(explicitInstance) {
|
|
221
|
-
const injected = inject(NOT_FORM_INSTANCE_KEY);
|
|
247
|
+
const injected = inject(NOT_FORM_INSTANCE_KEY, void 0);
|
|
222
248
|
const instance = explicitInstance ?? injected;
|
|
223
|
-
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
|
+
`);
|
|
224
253
|
return instance;
|
|
225
254
|
}
|
|
226
255
|
//#endregion
|
|
227
256
|
//#region src/components/not-form.vue
|
|
228
257
|
var not_form_default = /* @__PURE__ */ defineComponent({
|
|
229
258
|
__name: "not-form",
|
|
230
|
-
props: {
|
|
231
|
-
type:
|
|
259
|
+
props: { form: {
|
|
260
|
+
type: null,
|
|
232
261
|
required: true
|
|
233
262
|
} },
|
|
234
263
|
setup(__props) {
|
|
235
264
|
const attributes = useAttrs();
|
|
236
|
-
provideNotFormInstance(__props.
|
|
265
|
+
provideNotFormInstance(__props.form);
|
|
237
266
|
return (_ctx, _cache) => {
|
|
238
267
|
return openBlock(), createElementBlock("form", normalizeProps(guardReactiveProps(unref(attributes))), [renderSlot(_ctx.$slots, "default")], 16);
|
|
239
268
|
};
|
|
@@ -246,11 +275,11 @@ var not_field_default = /* @__PURE__ */ defineComponent({
|
|
|
246
275
|
__name: "not-field",
|
|
247
276
|
props: {
|
|
248
277
|
path: {
|
|
249
|
-
type:
|
|
278
|
+
type: String,
|
|
250
279
|
required: true
|
|
251
280
|
},
|
|
252
281
|
form: {
|
|
253
|
-
type:
|
|
282
|
+
type: null,
|
|
254
283
|
required: false
|
|
255
284
|
},
|
|
256
285
|
validateOn: {
|
|
@@ -260,67 +289,78 @@ var not_field_default = /* @__PURE__ */ defineComponent({
|
|
|
260
289
|
},
|
|
261
290
|
setup(__props) {
|
|
262
291
|
const props = __props;
|
|
263
|
-
const
|
|
292
|
+
const form = useNotFormInstance(props.form);
|
|
293
|
+
const validateOn = computed(() => ({
|
|
294
|
+
...form.validateOn,
|
|
295
|
+
...props.validateOn
|
|
296
|
+
}));
|
|
264
297
|
const isValidating = ref(false);
|
|
265
|
-
const
|
|
266
|
-
const
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
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
|
+
};
|
|
272
312
|
const validate = async () => {
|
|
273
313
|
isValidating.value = true;
|
|
274
314
|
try {
|
|
275
|
-
return await
|
|
315
|
+
return await form.validateField(props.path);
|
|
276
316
|
} finally {
|
|
277
317
|
isValidating.value = false;
|
|
278
318
|
}
|
|
279
319
|
};
|
|
280
|
-
const validateOn = computed(() => ({
|
|
281
|
-
...formInstance.validateOn,
|
|
282
|
-
...props.validateOn
|
|
283
|
-
}));
|
|
284
320
|
const onBlur = () => {
|
|
285
|
-
|
|
321
|
+
form.touchField(props.path);
|
|
286
322
|
if (validateOn.value.onBlur) validate();
|
|
287
323
|
};
|
|
288
|
-
const onChange = () => {
|
|
289
|
-
if (validateOn.value.onChange) validate();
|
|
290
|
-
};
|
|
291
324
|
const onInput = () => {
|
|
292
|
-
|
|
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();
|
|
293
333
|
};
|
|
294
334
|
const onFocus = () => {
|
|
295
335
|
if (validateOn.value.onFocus) validate();
|
|
296
336
|
};
|
|
297
|
-
const
|
|
298
|
-
path,
|
|
299
|
-
value,
|
|
300
|
-
isValidating,
|
|
301
|
-
validate,
|
|
302
|
-
errors,
|
|
303
|
-
touch,
|
|
304
|
-
unTouch,
|
|
305
|
-
dirty,
|
|
306
|
-
unDirty,
|
|
307
|
-
events: computed(() => ({
|
|
308
|
-
onBlur,
|
|
309
|
-
onInput,
|
|
310
|
-
onChange,
|
|
311
|
-
onFocus
|
|
312
|
-
})),
|
|
337
|
+
const events = computed(() => ({
|
|
313
338
|
onBlur,
|
|
314
339
|
onInput,
|
|
315
340
|
onChange,
|
|
316
341
|
onFocus
|
|
317
|
-
});
|
|
342
|
+
}));
|
|
318
343
|
onMounted(async () => {
|
|
319
344
|
await nextTick();
|
|
320
345
|
if (validateOn.value.onMount) validate();
|
|
321
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
|
+
}));
|
|
322
362
|
return (_ctx, _cache) => {
|
|
323
|
-
return renderSlot(_ctx.$slots, "default", normalizeProps(guardReactiveProps(
|
|
363
|
+
return renderSlot(_ctx.$slots, "default", normalizeProps(guardReactiveProps(slotProps.value)));
|
|
324
364
|
};
|
|
325
365
|
}
|
|
326
366
|
});
|
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",
|