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.
Files changed (3) hide show
  1. package/dist/index.d.ts +187 -2837
  2. package/dist/index.js +319 -22
  3. 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
- /** Injection key for the current active form instance */
232
+ /** The injection key for the NotForm instance. */
4
233
  const NOT_FORM_INSTANCE_KEY = Symbol("notform:instance");
5
234
  /**
6
- * Provides a form instance to all descendant components.
7
- * @param instance The form instance to provide.
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
- * Resolves the active form instance from context or an explicit prop override.
14
- * @param explicitInstance Optional instance passed directly via :form prop — takes priority over injected context.
15
- * @throws If no instance is found from either source.
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("[NotForm] No form instance found. Add a <NotForm :form=\"...\"> ancestor or pass :form directly.");
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: { instance: {
28
- type: Object,
259
+ props: { form: {
260
+ type: null,
29
261
  required: true
30
262
  } },
31
263
  setup(__props) {
32
264
  const attributes = useAttrs();
33
- provideNotFormInstance(__props.instance);
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 formInstance = useNotFormInstance(props.form);
57
- const path = computed(() => props.path);
58
- const errors = formInstance.getFieldErrors(path.value);
59
- const validate = () => formInstance.validateField(path.value);
60
- const fieldInstance = reactive({
61
- path: path.value,
62
- errors,
63
- validate
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(fieldInstance)));
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.2",
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",