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.
Files changed (3) hide show
  1. package/dist/index.d.ts +178 -2861
  2. package/dist/index.js +159 -119
  3. 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 values = ref(klona(initialValues));
44
- const errors = ref([...initialErrors]);
45
- const touchedFields = ref(/* @__PURE__ */ new Set());
46
- const dirtyFields = ref(/* @__PURE__ */ new Set());
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.value.reduce((accumulator, issue) => {
51
- const path = issue.path?.join(".");
52
- if (path && !accumulator[path]) accumulator[path] = issue.message;
53
- return accumulator;
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.value, path, value);
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 setValues = (newValues) => {
91
- for (const [path, value] of Object.entries(newValues)) setValue(path, value);
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
- errors.value = [...errors.value.filter((error) => error.path?.join(".") !== newPath), 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);
96
120
  };
97
121
  const setErrors = (newIssues) => {
98
- errors.value = [...newIssues];
122
+ errors.splice(0, errors.length, ...newIssues);
99
123
  };
100
124
  const clearErrors = () => {
101
- errors.value = [];
125
+ errors.splice(0, errors.length);
102
126
  };
103
127
  const getFieldErrors = (path) => {
104
- const pathArray = parsePath(path);
105
- return errors.value.filter((error) => isIssuePathEqual(error.path, pathArray));
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 toValue(config.schema)["~standard"].validate(values.value);
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 toValue(config.schema)["~standard"].validate(values.value);
125
- const pathArray = parsePath(path);
126
- errors.value = errors.value.filter((error) => !isIssuePathEqual(error.path, pathArray));
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, pathArray));
156
+ const fieldIssues = result.issues.filter((issue) => isIssuePathEqual(issue.path, pathSegments));
129
157
  if (fieldIssues.length > 0) {
130
- errors.value = [...errors.value, ...fieldIssues];
158
+ errors.push(...fieldIssues);
131
159
  return { issues: fieldIssues };
132
160
  }
133
- return { value: getProperty(values.value, path) };
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
- values.value = klona(initialValues);
164
- errors.value = klona(initialErrors);
165
- touchedFields.value.clear();
166
- dirtyFields.value.clear();
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
- const resolvedInstance = {
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
- /** Injection key for the current active form instance */
232
+ /** The injection key for the NotForm instance. */
207
233
  const NOT_FORM_INSTANCE_KEY = Symbol("notform:instance");
208
234
  /**
209
- * Provides a form instance to all descendant components.
210
- * @param instance The form instance to provide.
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
- * Resolves the active form instance from context or an explicit prop override.
217
- * @param explicitInstance Optional instance passed directly via :form prop — takes priority over injected context.
218
- * @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.
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("[NotForm] No form instance found. Add a <NotForm :instance=\"...\"> 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
+ `);
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: { instance: {
231
- type: Object,
259
+ props: { form: {
260
+ type: null,
232
261
  required: true
233
262
  } },
234
263
  setup(__props) {
235
264
  const attributes = useAttrs();
236
- provideNotFormInstance(__props.instance);
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: null,
278
+ type: String,
250
279
  required: true
251
280
  },
252
281
  form: {
253
- type: Object,
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 formInstance = useNotFormInstance(props.form);
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 path = computed(() => props.path);
266
- const value = computed(() => getProperty(formInstance.values.value, props.path));
267
- const errors = computed(() => formInstance.getFieldErrors(path.value));
268
- const touch = () => formInstance.touchField(path.value);
269
- const unTouch = () => formInstance.unTouchField(path.value);
270
- const dirty = () => formInstance.dirtyField(path.value);
271
- const unDirty = () => formInstance.unDirtyField(path.value);
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 formInstance.validateField(path.value);
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
- touch();
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
- if (validateOn.value.onInput) validate();
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 fieldInstance = reactive({
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(fieldInstance)));
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",
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",