notform 2.1.3 → 2.2.0

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 CHANGED
@@ -3,7 +3,7 @@
3
3
  <p><strong>Vue Forms Without the Friction</strong></p>
4
4
  <p>
5
5
  <a href="https://npmx.dev/package/notform"><img src="https://img.shields.io/npm/v/notform.svg?style=plastic&label=NPM%20Version&color=blue" alt="NPM Version"></a>
6
- <a href="https://npmx.dev/package/notform"><img src="https://img.shields.io/npm/dw/notform.svg?style=plastic&label=NPM%20Downloads&color=blue" alt="NPM Downloads"></a>
6
+ <a href="https://npmx.dev/package/notform"><img src="https://img.shields.io/npm/dt/notform.svg?style=plastic&label=NPM%20Downloads&color=blue" alt="NPM Downloads"></a>
7
7
  <a href="https://npmx.dev/package/notform"><img src="https://img.shields.io/npm/unpacked-size/notform?style=plastic&label=NPM%20Unpacked%20Size&color=blue" alt="NPM Unpacked Size"></a>
8
8
  </p>
9
9
  </div>
@@ -37,13 +37,13 @@ import { NotField, NotForm, NotMessage, useNotForm } from 'notform'
37
37
  import { z } from 'zod'
38
38
 
39
39
  const schema = z.object({
40
- email: z.string().email('Invalid email'),
40
+ email: z.email('Invalid email'),
41
41
  name: z.string().min(1, 'Name is required'),
42
42
  })
43
43
 
44
44
  const form = useNotForm({
45
- onSubmit: (values) => {
46
- console.log('Form submitted:', values)
45
+ onSubmit(values) {
46
+ console.log('Form:', values)
47
47
  },
48
48
  schema,
49
49
  })
@@ -51,30 +51,51 @@ const form = useNotForm({
51
51
 
52
52
  <template>
53
53
  <NotForm
54
- :form="form"
54
+ :form
55
55
  @submit="form.submit"
56
+ @reset="form.reset()"
56
57
  >
57
- <NotField path="name">
58
- <input
59
- v-model="form.values.name"
60
- type="text"
61
- >
62
-
63
- <NotMessage path="name" />
58
+ <NotField
59
+ v-slot="{events,path}"
60
+ path="name"
61
+ >
62
+ <label :for="path">
63
+ Name
64
+ <input
65
+ v-bind="events"
66
+ :id="path"
67
+ v-model="form.values.name"
68
+ type="text"
69
+ >
70
+ </label>
71
+
72
+ <NotMessage :path="path" />
64
73
  </NotField>
65
74
 
66
- <NotField path="email">
67
- <input
68
- v-model="form.values.email"
69
- type="email"
70
- >
71
-
72
- <NotMessage path="email" />
75
+ <NotField
76
+ v-slot="{events,path}"
77
+ path="email"
78
+ >
79
+ <label :for="path">
80
+ Email
81
+ <input
82
+ v-bind="events"
83
+ :id="path"
84
+ v-model="form.values.email"
85
+ type="email"
86
+ >
87
+ </label>
88
+
89
+ <NotMessage :path="path" />
73
90
  </NotField>
74
91
 
75
92
  <button type="submit">
76
93
  Submit
77
94
  </button>
95
+
96
+ <button type="reset">
97
+ Reset Form
98
+ </button>
78
99
  </NotForm>
79
100
  </template>
80
101
  ```
package/dist/index.d.ts CHANGED
@@ -44,6 +44,23 @@ type ObjectSchema = StandardSchemaV1 & {
44
44
  };
45
45
  };
46
46
  };
47
+ /**
48
+ * Represents a segment of a validation path.
49
+ * It can be either a string (for object properties) or a number (for array indices).
50
+ */
51
+ type Segment = PropertyKey | StandardSchemaV1.PathSegment;
52
+ /**
53
+ * Maps the index an array item occupied before a mutation to the index it
54
+ * occupies after that mutation, or to `undefined` when the mutation removed the
55
+ * item entirely.
56
+ *
57
+ * Each array mutation in `NotArrayField` (`prepend`, `remove`, `insert`,
58
+ * `swap`, `move`) builds one of these to describe exactly how it reshuffles
59
+ * indices, then passes it to `remapArrayFieldState`.
60
+ * @param previousIndex The index the item occupied before the mutation.
61
+ * @returns The index the item occupies after the mutation, or `undefined` if the item was removed.
62
+ */
63
+ type ArrayItemIndexMap = (previousIndex: number) => number | undefined;
47
64
  //#endregion
48
65
  //#region src/types/use-not-form.d.ts
49
66
  /**
@@ -64,9 +81,9 @@ interface UseNotFormConfig<TSchema extends ObjectSchema> {
64
81
  validateOn?: Partial<Record<ValidationTrigger, boolean>>;
65
82
  /**
66
83
  * The validation mode of the form.
67
- * @default { eager: true }
84
+ * @default 'eager'
68
85
  */
69
- validationMode?: Partial<Record<ValidationMode, boolean>>;
86
+ validationMode?: ValidationMode;
70
87
  /**
71
88
  * Callback triggered when form validation passes and the form is submitted.
72
89
  * @param values The validated output data from the schema.
@@ -349,7 +366,7 @@ declare const __VLS_export$3: <TSchema extends ObjectSchema, TItemSchema extends
349
366
  slots: NotArrayFieldSlots<TSchema, TItem>;
350
367
  emit: {};
351
368
  }>) => import("vue").VNode & {
352
- __ctx?: Awaited<typeof __VLS_setup>;
369
+ __ctx?: NonNullable<Awaited<typeof __VLS_setup>>;
353
370
  };
354
371
  declare const _default: typeof __VLS_export$3;
355
372
  type __VLS_PrettifyLocal$1<T> = (T extends any ? { [K in keyof T]: T[K] } : { [K in keyof T as K]: T[K] }) & {};
@@ -466,7 +483,7 @@ declare const __VLS_export$2: <TSchema extends ObjectSchema>(__VLS_props: NonNul
466
483
  slots: NotFieldSlots<TSchema>;
467
484
  emit: {};
468
485
  }>) => import("vue").VNode & {
469
- __ctx?: Awaited<typeof __VLS_setup>;
486
+ __ctx?: NonNullable<Awaited<typeof __VLS_setup>>;
470
487
  };
471
488
  declare const _default$1: typeof __VLS_export$2;
472
489
  type __VLS_PrettifyLocal<T> = (T extends any ? { [K in keyof T]: T[K] } : { [K in keyof T as K]: T[K] }) & {};
@@ -535,4 +552,4 @@ type __VLS_WithSlots<T, S> = T & {
535
552
  */
536
553
  declare function useNotForm<TSchema extends ObjectSchema>(config: UseNotFormConfig<TSchema>): NotFormInstance<TSchema>;
537
554
  //#endregion
538
- export { type DeepPartial, _default as NotArrayField, type NotArrayFieldItem, type NotArrayFieldProps, type NotArrayFieldSlots, _default$1 as NotField, type NotFieldProps, type NotFieldSlots, _default$2 as NotForm, type NotFormInstance, type NotFormProps, type NotFormSlots, _default$3 as NotMessage, type NotMessageProps, type NotMessageSlots, type ObjectSchema, type Paths, type UseNotFormConfig, type ValidationMode, type ValidationTrigger, useNotForm };
555
+ export { type ArrayItemIndexMap, type DeepPartial, _default as NotArrayField, type NotArrayFieldItem, type NotArrayFieldProps, type NotArrayFieldSlots, _default$1 as NotField, type NotFieldProps, type NotFieldSlots, _default$2 as NotForm, type NotFormInstance, type NotFormProps, type NotFormSlots, _default$3 as NotMessage, type NotMessageProps, type NotMessageSlots, type ObjectSchema, type Paths, type Segment, type UseNotFormConfig, type ValidationMode, type ValidationTrigger, useNotForm };
package/dist/index.js CHANGED
@@ -2,7 +2,137 @@ import { computed, createBlock, createCommentVNode, createElementBlock, createTe
2
2
  import { dequal } from "dequal";
3
3
  import { deepKeys, deleteProperty, getProperty, hasProperty, parsePath, setProperty } from "dot-prop";
4
4
  import { klona } from "klona/full";
5
- //#region src/utils/instance-utils.ts
5
+ //#region src/utils/form.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
+ * Compares two individual path segments for equality
17
+ * @param firstSegment The first segment to compare.
18
+ * @param secondSegment The second segment to compare.
19
+ * @returns True if the two segments refer to the same object key or array index.
20
+ */
21
+ function arePathSegmentsEqual(firstSegment, secondSegment) {
22
+ if (typeof firstSegment === "number" || typeof secondSegment === "number") return Number(firstSegment) === Number(secondSegment);
23
+ return firstSegment === secondSegment;
24
+ }
25
+ /**
26
+ * Checks if a validation issue path matches a target field path.
27
+ * @param issuePath The path array from the validation issue.
28
+ * @param targetPath The normalized path to compare against.
29
+ * @returns True if the paths are equivalent.
30
+ */
31
+ function isIssuePathEqual(issuePath, targetPath) {
32
+ if (!issuePath) return false;
33
+ if (issuePath.length !== targetPath.length) return false;
34
+ return issuePath.every((segment, segmentIndex) => {
35
+ const normalizedSegment = normalizeSegment(segment);
36
+ const targetSegment = targetPath[segmentIndex];
37
+ return arePathSegmentsEqual(normalizedSegment, targetSegment);
38
+ });
39
+ }
40
+ //#endregion
41
+ //#region src/utils/array-field.ts
42
+ /**
43
+ * Finds where a path sits inside an array field, if at all.
44
+ * @param pathSegments The full path segments to test, e.g. the parsed segments of `"tags.1.label"`.
45
+ * @param arrayFieldPathSegments The parsed path segments of the array field itself, e.g. the segments of `"tags"`.
46
+ * @returns The item's index and any trailing segments, or undefined if the path isn't inside this array.
47
+ */
48
+ function getArrayItemLocation(pathSegments, arrayFieldPathSegments) {
49
+ if (pathSegments.length <= arrayFieldPathSegments.length) return;
50
+ for (const [segmentIndex, arrayFieldPathSegment] of arrayFieldPathSegments.entries()) if (!arePathSegmentsEqual(pathSegments[segmentIndex], arrayFieldPathSegment)) return;
51
+ const itemIndex = Number(pathSegments[arrayFieldPathSegments.length]);
52
+ if (Number.isNaN(itemIndex)) return;
53
+ return {
54
+ itemIndex,
55
+ remainingSegments: pathSegments.slice(arrayFieldPathSegments.length + 1)
56
+ };
57
+ }
58
+ /**
59
+ * Rebuilds a dot-separated path from an array field's path, an item index, and any trailing segments.
60
+ * @param arrayFieldPath The array field's dot-separated path, e.g. `"tags"`.
61
+ * @param itemIndex The item's index within the array.
62
+ * @param remainingSegments Segments after the index, e.g. `['label']` for a nested field.
63
+ * @returns The reconstructed path, e.g. `"tags.0.label"`.
64
+ */
65
+ function buildArrayItemPath(arrayFieldPath, itemIndex, remainingSegments) {
66
+ return [
67
+ arrayFieldPath,
68
+ itemIndex,
69
+ ...remainingSegments
70
+ ].join(".");
71
+ }
72
+ /**
73
+ * Relocates or drops `form.errors` entries that belong to an item that moved or was removed.
74
+ * Errors on the array field itself (no index segment) are left untouched.
75
+ * @param form The form instance whose errors should be remapped.
76
+ * @param arrayFieldPathSegments The parsed path segments of the array field.
77
+ * @param arrayItemIndexMap Maps each previous item index to its new index, or undefined if removed.
78
+ */
79
+ function remapArrayFieldErrors(form, arrayFieldPathSegments, arrayItemIndexMap) {
80
+ for (let errorIndex = form.errors.length - 1; errorIndex >= 0; errorIndex--) {
81
+ const issue = form.errors[errorIndex];
82
+ if (issue.path) {
83
+ const itemLocation = getArrayItemLocation(issue.path.map((segment) => normalizeSegment(segment)), arrayFieldPathSegments);
84
+ if (itemLocation) {
85
+ const newItemIndex = arrayItemIndexMap(itemLocation.itemIndex);
86
+ if (typeof newItemIndex === "number") form.errors[errorIndex] = {
87
+ ...issue,
88
+ path: [
89
+ ...arrayFieldPathSegments,
90
+ newItemIndex,
91
+ ...itemLocation.remainingSegments
92
+ ]
93
+ };
94
+ else form.errors.splice(errorIndex, 1);
95
+ }
96
+ }
97
+ }
98
+ }
99
+ /**
100
+ * Relocates or drops entries in a `touchedFields`/`dirtyFields` set that belong to an item that moved or was removed.
101
+ * @param fieldPathSet The reactive set of field paths to remap.
102
+ * @param arrayFieldPath The dot-separated path of the array field.
103
+ * @param arrayFieldPathSegments The parsed path segments of the array field.
104
+ * @param arrayItemIndexMap Maps each previous item index to its new index, or undefined if removed.
105
+ */
106
+ function remapArrayFieldPathSet(fieldPathSet, arrayFieldPath, arrayFieldPathSegments, arrayItemIndexMap) {
107
+ const pathsToRemove = [];
108
+ const pathsToAdd = [];
109
+ for (const fieldPath of fieldPathSet) {
110
+ const itemLocation = getArrayItemLocation(parsePath(fieldPath), arrayFieldPathSegments);
111
+ if (itemLocation) {
112
+ pathsToRemove.push(fieldPath);
113
+ const newItemIndex = arrayItemIndexMap(itemLocation.itemIndex);
114
+ if (typeof newItemIndex === "number") pathsToAdd.push(buildArrayItemPath(arrayFieldPath, newItemIndex, itemLocation.remainingSegments));
115
+ }
116
+ }
117
+ for (const fieldPath of pathsToRemove) fieldPathSet.delete(fieldPath);
118
+ for (const fieldPath of pathsToAdd) fieldPathSet.add(fieldPath);
119
+ }
120
+ /**
121
+ * Keeps an array field's errors, touched paths, and dirty paths attached to the item they
122
+ * describe, rather than the array slot that item happened to occupy. Call before applying
123
+ * the mutation to `form.values`.
124
+ * @param form The form instance whose error, touched, and dirty state should be remapped.
125
+ * @param arrayFieldPath The dot-separated path of the array field being mutated, e.g. `"tags"`.
126
+ * @param arrayItemIndexMap Maps each previous item index to its new index, or undefined if removed.
127
+ */
128
+ function remapArrayFieldState(form, arrayFieldPath, arrayItemIndexMap) {
129
+ const arrayFieldPathSegments = parsePath(arrayFieldPath);
130
+ remapArrayFieldErrors(form, arrayFieldPathSegments, arrayItemIndexMap);
131
+ remapArrayFieldPathSet(form.touchedFields, arrayFieldPath, arrayFieldPathSegments, arrayItemIndexMap);
132
+ remapArrayFieldPathSet(form.dirtyFields, arrayFieldPath, arrayFieldPathSegments, arrayItemIndexMap);
133
+ }
134
+ //#endregion
135
+ //#region src/utils/instance.ts
6
136
  /** The injection key for the NotForm instance. */
7
137
  const NOT_FORM_INSTANCE_KEY = Symbol("notform:instance");
8
138
  /**
@@ -137,6 +267,7 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
137
267
  */
138
268
  function prepend(value) {
139
269
  itemKeys.value.unshift(generateKey());
270
+ remapArrayFieldState(form, props.path, (previousIndex) => previousIndex + 1);
140
271
  mutate((current) => current.unshift(value));
141
272
  }
142
273
  /**
@@ -145,6 +276,10 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
145
276
  */
146
277
  function remove(index) {
147
278
  itemKeys.value.splice(index, 1);
279
+ remapArrayFieldState(form, props.path, (previousIndex) => {
280
+ if (previousIndex === index) return;
281
+ return previousIndex > index ? previousIndex - 1 : previousIndex;
282
+ });
148
283
  mutate((current) => current.splice(index, 1));
149
284
  }
150
285
  /**
@@ -154,6 +289,10 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
154
289
  */
155
290
  function insert(index, value) {
156
291
  itemKeys.value.splice(index, 0, generateKey());
292
+ remapArrayFieldState(form, props.path, (previousIndex) => {
293
+ if (previousIndex >= index) return previousIndex + 1;
294
+ return previousIndex;
295
+ });
157
296
  mutate((current) => current.splice(index, 0, value));
158
297
  }
159
298
  /**
@@ -172,13 +311,18 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
172
311
  * @param indexB The second index
173
312
  */
174
313
  function swap(indexA, indexB) {
175
- const tempKey = itemKeys.value[indexA];
314
+ const temporaryKey = itemKeys.value[indexA];
176
315
  itemKeys.value[indexA] = itemKeys.value[indexB];
177
- itemKeys.value[indexB] = tempKey;
316
+ itemKeys.value[indexB] = temporaryKey;
317
+ remapArrayFieldState(form, props.path, (previousIndex) => {
318
+ if (previousIndex === indexA) return indexB;
319
+ if (previousIndex === indexB) return indexA;
320
+ return previousIndex;
321
+ });
178
322
  mutate((current) => {
179
- const temp = current[indexA];
323
+ const temporaryValue = current[indexA];
180
324
  current[indexA] = current[indexB];
181
- current[indexB] = temp;
325
+ current[indexB] = temporaryValue;
182
326
  });
183
327
  }
184
328
  /**
@@ -187,11 +331,20 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
187
331
  * @param to The destination index
188
332
  */
189
333
  function move(from, to) {
190
- const [key] = itemKeys.value.splice(from, 1);
191
- itemKeys.value.splice(to, 0, key);
334
+ const [movedItemKey] = itemKeys.value.splice(from, 1);
335
+ itemKeys.value.splice(to, 0, movedItemKey);
336
+ remapArrayFieldState(form, props.path, (previousIndex) => {
337
+ if (previousIndex === from) return to;
338
+ if (from < to) {
339
+ if (previousIndex > from && previousIndex <= to) return previousIndex - 1;
340
+ return previousIndex;
341
+ }
342
+ if (previousIndex >= to && previousIndex < from) return previousIndex + 1;
343
+ return previousIndex;
344
+ });
192
345
  mutate((current) => {
193
- const [item] = current.splice(from, 1);
194
- current.splice(to, 0, item);
346
+ const [movedItem] = current.splice(from, 1);
347
+ current.splice(to, 0, movedItem);
195
348
  });
196
349
  }
197
350
  onMounted(async () => {
@@ -301,13 +454,13 @@ var not_field_default = /* @__PURE__ */ defineComponent({
301
454
  function onInput() {
302
455
  updateDirty();
303
456
  if (!validateOn.value.onInput) return;
304
- if (form.validationMode.eager && !isValid.value) scheduleValidation();
457
+ if (form.validationMode === "eager" && !isValid.value) scheduleValidation();
305
458
  }
306
459
  /** Handles the change event for the field. */
307
460
  function onChange() {
308
461
  updateDirty();
309
462
  if (!validateOn.value.onChange) return;
310
- if (form.validationMode.eager && !isValid.value) scheduleValidation();
463
+ if (form.validationMode === "eager" && !isValid.value) scheduleValidation();
311
464
  }
312
465
  /** Handles the focus event for the field. */
313
466
  function onFocus() {
@@ -388,33 +541,6 @@ var not_message_default = /* @__PURE__ */ defineComponent({
388
541
  }
389
542
  });
390
543
  //#endregion
391
- //#region src/utils/form-utils.ts
392
- /**
393
- * Normalizes a validation path segment into a standard property key.
394
- * @param segment The path segment to normalize.
395
- * @returns The normalized key.
396
- */
397
- function normalizeSegment(segment) {
398
- if (typeof segment === "object" && segment !== null && "key" in segment) return segment.key;
399
- return segment;
400
- }
401
- /**
402
- * Checks if a validation issue path matches a target field path.
403
- * @param issuePath The path array from the validation issue.
404
- * @param targetPath The normalized path to compare against.
405
- * @returns True if the paths are equivalent.
406
- */
407
- function isIssuePathEqual(issuePath, targetPath) {
408
- if (!issuePath) return false;
409
- if (issuePath.length !== targetPath.length) return false;
410
- return issuePath.every((segment, index) => {
411
- const normalizedSegment = normalizeSegment(segment);
412
- const targetSegment = targetPath[index];
413
- if (typeof normalizedSegment === "number" || typeof targetSegment === "number") return Number(normalizedSegment) === Number(targetSegment);
414
- return normalizedSegment === targetSegment;
415
- });
416
- }
417
- //#endregion
418
544
  //#region src/composables/use-not-form.ts
419
545
  /**
420
546
  * Creates a reactive NotFormInstance for managing form state and validation.
@@ -432,10 +558,7 @@ function useNotForm(config) {
432
558
  onInput: config.validateOn?.onInput ?? true,
433
559
  onMount: config.validateOn?.onMount ?? false
434
560
  };
435
- const validationMode = {
436
- eager: config.validationMode?.eager ?? true,
437
- lazy: config.validationMode?.lazy ?? false
438
- };
561
+ const validationMode = config.validationMode || "eager";
439
562
  const values = reactive(klona(initialValues));
440
563
  const errors = reactive([...initialErrors]);
441
564
  const touchedFields = reactive(/* @__PURE__ */ new Set());
@@ -590,7 +713,7 @@ function useNotForm(config) {
590
713
  const pathSegments = parsePath(path);
591
714
  const staleIndices = [];
592
715
  for (const [index, error] of errors.entries()) if (isIssuePathEqual(error.path, pathSegments)) staleIndices.push(index);
593
- for (let i = staleIndices.length - 1; i >= 0; i--) errors.splice(staleIndices[i], 1);
716
+ for (let index = staleIndices.length - 1; index >= 0; index--) errors.splice(staleIndices[index], 1);
594
717
  if (result?.issues) {
595
718
  const fieldIssues = result.issues.filter((issue) => isIssuePathEqual(issue.path, pathSegments));
596
719
  if (fieldIssues.length > 0) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "notform",
3
3
  "type": "module",
4
- "version": "2.1.3",
4
+ "version": "2.2.0",
5
5
  "private": false,
6
6
  "description": "Vue Forms Without the Friction",
7
7
  "author": "Favour Emeka <favorodera@gmail.com>",
@@ -49,18 +49,18 @@
49
49
  "klona": "^2.0.6"
50
50
  },
51
51
  "devDependencies": {
52
- "@favorodera/eslint-config": "^0.1.4",
53
- "@types/node": "^25.6.0",
54
- "@vitejs/plugin-vue": "^6.0.6",
55
- "@vitest/ui": "^4.1.4",
56
- "@vue/test-utils": "^2.4.6",
52
+ "@favorodera/eslint-config": "^1.0.3",
53
+ "@types/node": "^26.1.0",
54
+ "@vitejs/plugin-vue": "^6.0.7",
55
+ "@vitest/ui": "^4.1.9",
56
+ "@vue/test-utils": "^2.4.11",
57
57
  "happy-dom": "^20.10.6",
58
- "tsdown": "^0.22.2",
59
- "type-fest": "^5.5.0",
60
- "vite": "^8.0.16",
61
- "vitest": "^4.1.8",
62
- "vue": "^3.5.34",
63
- "vue-tsc": "^3.3.5"
58
+ "tsdown": "^0.22.3",
59
+ "type-fest": "^5.7.0",
60
+ "vite": "^8.1.3",
61
+ "vitest": "^4.1.9",
62
+ "vue": "^3.5.39",
63
+ "vue-tsc": "^3.3.6"
64
64
  },
65
65
  "scripts": {
66
66
  "build": "tsdown",