notform 2.2.3 → 2.2.5

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
@@ -1,14 +1,16 @@
1
1
  <div align="center">
2
2
  <h1><code>notform</code></h1>
3
- <p><strong>Headless Form Management, Seamlessly Integrated with Nuxt</strong></p>
3
+ <p><strong>Headless form management for Vue 3</strong></p>
4
4
  <p>
5
- <a href="https://npmx.dev/package/notform"><img src="https://img.shields.io/npm/v/notform.svg?style=plastic&label=NPM%20Version" alt="NPM Version"></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
- <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>
5
+ <a href="https://npmx.dev/package/notform"><img src="https://img.shields.io/npm/v/notform.svg?style=plastic&label=Version" alt="Version"></a>
6
+ <a href="https://npmx.dev/package/notform"><img src="https://img.shields.io/npm/dm/notform.svg?style=plastic&label=Downloads&color=blue" alt="Downloads"></a>
7
+ <a href="https://npmx.dev/package/notform"><img src="https://img.shields.io/npm/unpacked-size/notform?style=plastic&label=Unpacked%20Size" alt="Unpacked Size"></a>
8
8
  </p>
9
9
  </div>
10
10
 
11
- `notform` is the core package of the NotForm ecosystem. It provides headless form validation and state management for Vue 3 applications. Built with TypeScript from the ground up, it offers a composable API that integrates perfectly with Vue 3's Composition API and works with any Standard Schema-compatible validator.
11
+ `notform` is the core NotForm package for Vue 3.
12
+
13
+ It provides reactive form state, schema-based validation, field state, submission handling, and renderless components that work with any UI you choose.
12
14
 
13
15
  ## Installation
14
16
 
@@ -16,35 +18,31 @@
16
18
  pnpm add notform
17
19
  ```
18
20
 
19
- ## How It Works
20
-
21
- NotForm follows a **headless, composable-first** approach. Forms are managed through the `useNotForm` composable which handles validation, state, and submission logic in a type-safe manner.
21
+ Install a Standard Schema-compatible validator separately, for example:
22
22
 
23
- Each form consists of:
24
-
25
- - **A schema** — Defined using any validation library that supports [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType, etc.)
26
- - **Form state** — Managed reactively with full TypeScript support
27
- - **Validation** — Automatic validation based on your schema with error messages
28
- - **Submission handling** — Built-in submission lifecycle with loading states
29
- - **Your UI** — NotForm renders nothing—you bring your own components
23
+ ```bash
24
+ pnpm add zod
25
+ ```
30
26
 
31
27
  ## Basic Usage
32
28
 
33
- Import the components and composable you need:
34
-
35
29
  ```vue
36
30
  <script setup lang="ts">
37
31
  import { NotField, NotForm, NotMessage, useNotForm } from 'notform'
38
32
  import { z } from 'zod'
39
33
 
40
34
  const schema = z.object({
41
- email: z.email('Invalid email'),
42
- name: z.string().min(1, 'Name is required'),
35
+ email: z.email('Enter a valid email address'),
36
+ name: z.string('Enter a valid name'),
43
37
  })
44
38
 
45
39
  const form = useNotForm({
40
+ initialValues: {
41
+ email: '',
42
+ name: '',
43
+ },
46
44
  onSubmit(values) {
47
- console.log('Form:', values)
45
+ console.log('Submitted:', values)
48
46
  },
49
47
  schema,
50
48
  })
@@ -52,42 +50,48 @@ const form = useNotForm({
52
50
 
53
51
  <template>
54
52
  <NotForm
55
- :form
53
+ :form="form"
56
54
  @submit.prevent="form.submit"
57
55
  @reset="form.reset()"
58
56
  >
59
57
  <NotField
60
- v-slot="{events,path}"
58
+ v-slot="{ events, path }"
61
59
  path="name"
62
60
  >
63
- <label :for="path">
64
- Name
61
+ <div>
62
+ <label :for="path">Name</label>
63
+
65
64
  <input
66
- v-bind="events"
67
65
  :id="path"
68
66
  v-model="form.values.name"
67
+ v-bind="events"
68
+ name="name"
69
69
  type="text"
70
+ autocomplete="name"
70
71
  >
71
- </label>
72
72
 
73
- <NotMessage :path="path" />
73
+ <NotMessage :path="path" />
74
+ </div>
74
75
  </NotField>
75
76
 
76
77
  <NotField
77
- v-slot="{events,path}"
78
+ v-slot="{ events, path }"
78
79
  path="email"
79
80
  >
80
- <label :for="path">
81
- Email
81
+ <div>
82
+ <label :for="path">Email address</label>
83
+
82
84
  <input
83
- v-bind="events"
84
85
  :id="path"
85
86
  v-model="form.values.email"
87
+ v-bind="events"
88
+ name="email"
86
89
  type="email"
90
+ autocomplete="email"
87
91
  >
88
- </label>
89
92
 
90
- <NotMessage :path="path" />
93
+ <NotMessage :path="path" />
94
+ </div>
91
95
  </NotField>
92
96
 
93
97
  <button type="submit">
@@ -95,116 +99,87 @@ const form = useNotForm({
95
99
  </button>
96
100
 
97
101
  <button type="reset">
98
- Reset Form
102
+ Reset
99
103
  </button>
100
104
  </NotForm>
101
105
  </template>
102
106
  ```
103
107
 
104
- ## Array Fields
108
+ ## API
105
109
 
106
- NotForm includes built-in support for dynamic array fields with add, remove, and reorder operations:
110
+ The core package exports:
107
111
 
108
- ```vue
109
- <script setup lang="ts">
110
- import { NotArrayField, NotField, NotForm, NotMessage, useNotForm } from 'notform'
111
- import { z } from 'zod'
112
+ - `useNotForm`
113
+ - `NotForm`
114
+ - `NotField`
115
+ - `NotMessage`
116
+ - `NotArrayField`
112
117
 
113
- const tagSchema = z.string().min(1, 'Tag cannot be empty')
118
+ The form instance manages values, errors, touched and dirty state, validation, submission state, and reset behavior.
114
119
 
115
- const schema = z.object({
116
- tags: z.array(tagSchema).min(1, 'At least one tag is required'),
117
- })
120
+ ## Array Fields
118
121
 
119
- const form = useNotForm({
120
- schema,
121
- })
122
- </script>
122
+ `NotArrayField` provides renderless operations for dynamic arrays while preserving stable item keys during reordering.
123
123
 
124
- <template>
125
- <NotForm
126
- :form
127
- @submit.prevent="form.submit"
124
+ ```vue
125
+ <NotArrayField
126
+ path="tags"
127
+ v-slot="{ items, append, remove }"
128
+ >
129
+ <div
130
+ v-for="(item, index) in items"
131
+ :key="item.key"
128
132
  >
129
- <NotArrayField
130
- v-slot="{ items, append, remove }"
131
- path="tags"
132
- :item-schema="tagSchema"
133
- >
134
- <div
135
- v-for="(item, index) in items"
136
- :key="item.key"
133
+ <NotField :path="item.path" v-slot="{ events, path }">
134
+ <label :for="path">Tag {{ index + 1 }}</label>
135
+ <input
136
+ :id="path"
137
+ v-model="form.values.tags[index]"
138
+ v-bind="events"
139
+ :name="path"
140
+ type="text"
137
141
  >
138
- <NotField
139
- v-slot="{ events }"
140
- :path="item.path"
141
- >
142
- <input
143
- v-bind="events"
144
- :id="item.path"
145
- v-model="form.values.tags[index]"
146
- type="text"
147
- >
148
- </NotField>
149
-
150
- <button
151
- type="button"
152
- @click="remove(index)"
153
- >
154
- Remove
155
- </button>
156
- </div>
142
+ <NotMessage :path="path" />
143
+ </NotField>
157
144
 
158
- <button
159
- type="button"
160
- @click="append('')"
161
- >
162
- Add Tag
163
- </button>
145
+ <button type="button" @click="remove(index)">
146
+ Remove
147
+ </button>
148
+ </div>
164
149
 
165
- <NotMessage path="tags" />
166
- </NotArrayField>
167
- </NotForm>
168
- </template>
150
+ <button type="button" @click="append('')">
151
+ Add tag
152
+ </button>
153
+ </NotArrayField>
169
154
  ```
170
155
 
171
- ## Validation Libraries
172
-
173
- NotForm works with any validation library that implements the Standard Schema interface:
156
+ ## Type Safety
174
157
 
175
- - **Zod** — TypeScript-first schema validation
176
- - **Valibot** — Modular and type-safe schema validation
177
- - **ArkType** — High-performance runtime type checking
178
- - And any other Standard Schema-compatible library
158
+ Field paths and related APIs are inferred from your schema, including nested paths.
179
159
 
180
- ## Components and Composables
181
-
182
- ### Composables
183
-
184
- - `useNotForm` — Main composable for creating form instances
185
-
186
- ### Components
160
+ ```ts
161
+ form.setValue('email', 'jane@example.com')
162
+ await form.validateField('email')
163
+ const errors = form.getFieldErrors('email')
164
+ ```
187
165
 
188
- - `NotForm` — Form wrapper component that provides form context
189
- - `NotField` — Field component for individual form inputs
190
- - `NotMessage` — Error message display component
191
- - `NotArrayField` — Array field component for dynamic form arrays
166
+ ## Requirements
192
167
 
193
- ## Features
168
+ - Vue 3
169
+ - Node.js 24 or later for development in this repository
170
+ - A Standard Schema-compatible validator
194
171
 
195
- - **Headless** — Renders nothing. You bring the UI, NotForm brings the logic.
196
- - **Schema-agnostic** — Works with any Standard Schema validator (Zod, Valibot, ArkType, etc.)
197
- - **Type-safe** — Full TypeScript support with inferred types from your schema
198
- - **Composable** — Built for Vue 3 Composition API with a clean, intuitive API
199
- - **Lightweight** — Tiny footprint with tree-shaking support
200
- - **Array fields** — Built-in support for dynamic array fields with add/remove operations
201
- - **Flexible** — Use with any UI library—native HTML, Nuxt UI, Shadcn, or your own components
172
+ ## Development
202
173
 
203
- ## Documentation
174
+ From the repository root:
204
175
 
205
- For detailed guides, API reference, and examples, visit:
206
- **[notformdocs.vercel.app](https://notformdocs.vercel.app/)**
176
+ ```bash
177
+ pnpm install
178
+ pnpm --filter notform test
179
+ pnpm --filter notform typecheck
180
+ pnpm --filter notform build
181
+ ```
207
182
 
208
183
  ## License
209
184
 
210
- [MIT](../../LICENSE) &copy; [Favour Emeka](https://github.com/favorodera)
185
+ [MIT](../../LICENSE) © [Favour Emeka](https://github.com/favorodera)
package/dist/index.d.ts CHANGED
@@ -125,12 +125,14 @@ type NotFormInstance<TSchema extends ObjectSchema> = Raw<{
125
125
  *
126
126
  * Useful for deeply nested paths or custom inputs that do not use `v-model`.
127
127
  * Does **not** trigger validation — the field's event handlers are responsible for that.
128
+ * @param path The dot-separated path to the field.
129
+ * @param value The value to set.
128
130
  * @example
129
131
  * ```ts
130
132
  * form.setValue('address.city', 'Lagos')
131
133
  * ```
132
134
  */
133
- setValue: <const TPath extends Paths<StandardSchemaV1.InferInput<TSchema>>>(path: TPath, value: Get<StandardSchemaV1.InferInput<TSchema>, TPath, {
135
+ setValue: <TPath extends Paths<StandardSchemaV1.InferInput<TSchema>>>(path: TPath, value: Get<StandardSchemaV1.InferInput<TSchema>, TPath, {
134
136
  strict: false;
135
137
  }>) => void;
136
138
  /**
@@ -143,6 +145,11 @@ type NotFormInstance<TSchema extends ObjectSchema> = Raw<{
143
145
  /**
144
146
  * Marks a field as touched.
145
147
  * Called automatically by the field's `onBlur` handler.
148
+ * @param path The dot-separated path to the field.
149
+ * @example
150
+ * ```ts
151
+ * form.touchField('address.city')
152
+ * ```
146
153
  */
147
154
  touchField: (path: Paths<StandardSchemaV1.InferInput<TSchema>>) => void;
148
155
  /** The set of field paths whose current value differs from the initial value. */
@@ -152,6 +159,11 @@ type NotFormInstance<TSchema extends ObjectSchema> = Raw<{
152
159
  /**
153
160
  * Marks a field as dirty.
154
161
  * Called automatically when a field value changes.
162
+ * @param path The dot-separated path to the field.
163
+ * @example
164
+ * ```ts
165
+ * form.dirtyField('address.city')
166
+ * ```
155
167
  */
156
168
  dirtyField: (path: Paths<StandardSchemaV1.InferInput<TSchema>>) => void;
157
169
  /** The raw validation issues produced by the last validation run. */
@@ -171,14 +183,31 @@ type NotFormInstance<TSchema extends ObjectSchema> = Raw<{
171
183
  /**
172
184
  * Replaces an existing error for the same path, or appends it if none exists.
173
185
  * Useful for setting server-side errors after submission.
186
+ * @param error The validation issue to set.
187
+ * @example
188
+ * ```ts
189
+ * form.setError({ message: 'Invalid email', path: ['email'] })
190
+ * ```
174
191
  */
175
192
  setError: (error: StandardSchemaV1.Issue) => void;
176
- /** Replaces all current errors with the provided issues. */
193
+ /**
194
+ * Replaces all current errors with the provided issues.
195
+ * @param errors The validation issues to set.
196
+ * @example
197
+ * ```ts
198
+ * form.setErrors([
199
+ * { message: 'Invalid email', path: ['email'] },
200
+ * { message: 'Phone number must be at least 11 digits', path: ['phone'] }
201
+ * ])
202
+ * ```
203
+ */
177
204
  setErrors: (errors: Array<StandardSchemaV1.Issue>) => void;
178
205
  /** Removes all active validation errors. */
179
206
  clearErrors: () => void;
180
207
  /**
181
- * Returns all validation issues for a specific field path.
208
+ * Get all the validation issues for a specific field path.
209
+ * @param path The dot-separated path to the field.
210
+ * @returns An array of validation issues for the given field path.
182
211
  * @example
183
212
  * ```ts
184
213
  * form.getFieldErrors('address.city')
@@ -190,11 +219,18 @@ type NotFormInstance<TSchema extends ObjectSchema> = Raw<{
190
219
  /**
191
220
  * Validates the entire form against the schema.
192
221
  * Replaces all current errors with the result.
222
+ * @returns A promise that resolves to the result of the validation.
193
223
  */
194
224
  validate: () => Promise<StandardSchemaV1.Result<StandardSchemaV1.InferOutput<TSchema>>>;
195
225
  /**
196
226
  * Validates a single field against the schema.
197
227
  * Only replaces errors for that field — all other fields are left untouched.
228
+ * @param path The dot-separated path to the field.
229
+ * @returns A promise that resolves to the result of the validation.
230
+ * @example
231
+ * ```ts
232
+ * form.validateField('email')
233
+ * ```
198
234
  */
199
235
  validateField: (path: Paths<StandardSchemaV1.InferInput<TSchema>>) => Promise<StandardSchemaV1.Result<StandardSchemaV1.InferOutput<TSchema>>>;
200
236
  /** Whether the form currently has no validation errors. */
@@ -206,14 +242,8 @@ type NotFormInstance<TSchema extends ObjectSchema> = Raw<{
206
242
  *
207
243
  * Marks all fields as touched and dirty before validating so all errors surface.
208
244
  * If validation fails, submission is aborted.
209
- * Bind to the native form's `@submit` event:
210
- * @example
211
- * ```vue
212
- * <template>
213
- * <form v-on:submit="form.submit">
214
- * </form>
215
- * </template>
216
- * ```
245
+ * @param event The submit event.
246
+ * @returns A promise that resolves when the form is submitted.
217
247
  */
218
248
  submit: (event: SubmitEvent) => Promise<void>;
219
249
  /**
@@ -221,13 +251,20 @@ type NotFormInstance<TSchema extends ObjectSchema> = Raw<{
221
251
  *
222
252
  * Clears all touched and dirty tracking. If `values` or `errors` are passed,
223
253
  * they replace the stored baseline so subsequent resets return to the new state.
254
+ * @param values Optional new values to reset the form to.
255
+ * @param errors Optional new errors to reset the form to.
224
256
  * @example
225
257
  * ```ts
226
258
  * // Reset to original initial values
227
259
  * form.reset()
228
260
  *
229
- * // Reset to new values (becomes the new baseline)
230
- * form.reset({ name: 'Jane' })
261
+ * // Reset to new values and errors (becomes the new baseline)
262
+ * form.reset(
263
+ * { name: 'Jane' },
264
+ * [
265
+ * { message: 'Invalid email', path: ['email'] }
266
+ * ]
267
+ * )
231
268
  * ```
232
269
  */
233
270
  reset: (values?: DeepPartial<StandardSchemaV1.InferInput<TSchema>>, errors?: Array<StandardSchemaV1.Issue>) => void;
@@ -254,14 +291,6 @@ interface NotArrayFieldProps<TItemSchema extends StandardSchemaV1 = StandardSche
254
291
  /**
255
292
  * Schema for a single array item — used purely for type inference.
256
293
  * Enables typed `append`, `prepend`, `insert`, and `update` methods in the slot.
257
- * @example
258
- * ```vue
259
- * <template>
260
- * <NotArrayField path="tags" :item-schema="z.string()">
261
- * <!-- append now expects a string -->
262
- * </NotArrayField>
263
- * </template>
264
- * ```
265
294
  */
266
295
  itemSchema?: TItemSchema;
267
296
  /**
@@ -276,22 +305,9 @@ interface NotArrayFieldProps<TItemSchema extends StandardSchemaV1 = StandardSche
276
305
  */
277
306
  validateOn?: Partial<Record<Extract<ValidationTrigger, 'onChange' | 'onMount'>, boolean>>;
278
307
  }
279
- /**
280
- * Represents a single item in the array field.
281
- * Use `key` for `v-for` tracking and `path` to pass to a nested `NotField`.
282
- * @example
283
- * ```vue
284
- * <template>
285
- * <NotArrayField path="tags" v-slot="{ items }">
286
- * <NotField v-for="item in items" :key="item.key" :path="item.path" v-slot="{ events }">
287
- * <input v-model="form.values.tags[item.index]" v-bind="events" />
288
- * </NotField>
289
- * </NotArrayField>
290
- * </template>
291
- * ```
292
- */
308
+ /** Represents a single item in the array field. */
293
309
  interface NotArrayFieldItem {
294
- /** Stable key for `v-for` — does not change when items are reordered. */
310
+ /** Stable key used for looping — does not change when items are reordered. */
295
311
  key: string;
296
312
  /** Current index of this item in the array. */
297
313
  index: number;
@@ -308,7 +324,7 @@ interface NotArrayFieldSlots<TSchema extends ObjectSchema, TItem = any> {
308
324
  default: (props: {
309
325
  /** The dot-separated path to this array field. */
310
326
  path: string;
311
- /** The array items with stable keys and paths for use with `v-for`. */
327
+ /** The array items with stable keys and paths. */
312
328
  items: Array<NotArrayFieldItem>;
313
329
  /** All validation issues for this array field from the last validation run. */
314
330
  errors: Array<StandardSchemaV1.Issue>;
@@ -329,6 +345,7 @@ interface NotArrayFieldSlots<TSchema extends ObjectSchema, TItem = any> {
329
345
  /**
330
346
  * Manually triggers validation for this array field.
331
347
  * Useful when mutations are performed programmatically outside of the normal flow.
348
+ * @returns A promise that resolves to the result of the validation.
332
349
  */
333
350
  validate: () => ReturnType<NotFormInstance<TSchema>['validateField']>;
334
351
  /**
@@ -396,27 +413,12 @@ interface NotFieldProps {
396
413
  /**
397
414
  * Explicit form instance override.
398
415
  * Takes priority over the instance provided by a `NotForm` ancestor.
399
- * Required when using `NotField` outside of a `NotForm` (singleton fields).
400
- * @example
401
- * ```vue
402
- * <template>
403
- * <NotField :form="form" path="email" v-slot="{ events }">
404
- * <input v-bind="events" />
405
- * </NotField>
406
- * </template>
407
- * ```
416
+ * Required when using `NotField` outside of a `NotForm`.
408
417
  */
409
418
  form?: NotFormInstance<any>;
410
419
  /**
411
420
  * Per-field validation trigger overrides.
412
421
  * Merged over the form-wide `validateOn` — only the keys you specify are overridden.
413
- * @example
414
- * ```vue
415
- * <template>
416
- * <!-- form validates on blur only, but this field also validates on every input -->
417
- * <NotField :validateOn="{ onInput: true }" path="username" />
418
- * </template>
419
- * ```
420
422
  */
421
423
  validateOn?: Partial<Record<ValidationTrigger, boolean>>;
422
424
  /**
@@ -429,15 +431,6 @@ interface NotFieldProps {
429
431
  *
430
432
  * Blur- and submit-triggered validation always runs immediately, regardless
431
433
  * of this setting, so the field never feels unresponsive when the user leaves.
432
- * @example
433
- * ```vue
434
- * <template>
435
- * <!-- validate 400ms after the user stops typing -->
436
- * <NotField path="username" :debounce="400" v-slot="{ events }">
437
- * <input v-model="form.values.username" v-bind="events" />
438
- * </NotField>
439
- * </template>
440
- * ```
441
434
  *
442
435
  * Omit or set to `0` to disable debouncing (default behaviour).
443
436
  */
@@ -471,6 +464,7 @@ interface NotFieldSlots<TSchema extends ObjectSchema> {
471
464
  /**
472
465
  * Manually triggers validation for this field.
473
466
  * Useful for custom inputs that manage their own interaction events.
467
+ * @returns A promise that resolves to the result of the validation.
474
468
  */
475
469
  validate: () => ReturnType<NotFormInstance<TSchema>['validateField']>;
476
470
  /**
@@ -539,13 +533,7 @@ interface NotMessageProps {
539
533
  /**
540
534
  * Explicit form instance override.
541
535
  * Takes priority over the instance provided by a `NotForm` ancestor.
542
- * Required when using `NotMessage` outside of a `NotForm` (singleton fields).
543
- * @example
544
- * ```vue
545
- * <template>
546
- * <NotMessage :form="form" path="email" />
547
- * </template>
548
- * ```
536
+ * Required when using `NotMessage` outside of a `NotForm`.
549
537
  */
550
538
  form?: NotFormInstance<any>;
551
539
  }
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { computed, createBlock, createCommentVNode, createElementBlock, createTextVNode, defineComponent, inject, markRaw, mergeProps, nextTick, normalizeProps, onMounted, onUnmounted, openBlock, provide, reactive, ref, renderSlot, resolveDynamicComponent, toDisplayString, toValue, watch, withCtx } from "vue";
1
+ import { computed, createBlock, createCommentVNode, createElementBlock, createTextVNode, defineComponent, inject, markRaw, mergeProps, nextTick, normalizeProps, onMounted, onUnmounted, openBlock, provide, reactive, ref, renderSlot, resolveDynamicComponent, toDisplayString, toValue, watch, withCtx, withModifiers } from "vue";
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";
@@ -184,6 +184,7 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
184
184
  const props = __props;
185
185
  const form = useNotFormInstance(props.form);
186
186
  const isValidating = ref(false);
187
+ const validatingCount = ref(0);
187
188
  /** Counter to ensure absolute uniqueness for generated keys. */
188
189
  const keyCounter = ref(0);
189
190
  /** Stable keys per item that survive reorders, removals, and inserts. */
@@ -216,13 +217,31 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
216
217
  return form.dirtyFields.has(props.path) || [...form.dirtyFields].some((path) => path.startsWith(`${props.path}.`));
217
218
  });
218
219
  /**
220
+ * Starts a local field validation execution.
221
+ */
222
+ function beginValidation() {
223
+ validatingCount.value++;
224
+ isValidating.value = true;
225
+ }
226
+ /**
227
+ * Ends a local field validation execution.
228
+ */
229
+ function endValidation() {
230
+ validatingCount.value--;
231
+ if (validatingCount.value === 0) isValidating.value = false;
232
+ }
233
+ /**
219
234
  * Generates a unique key string using the field path and counter.
220
- * @returns A unique key string
235
+ * @returns A unique key string.
221
236
  */
222
237
  function generateKey() {
223
238
  return `${props.path}-${keyCounter.value++}`;
224
239
  }
225
- /** Re-aligns itemKeys with current array length (used mostly after external resets) */
240
+ /**
241
+ * Re-aligns itemKeys with current array length.
242
+ *
243
+ * Used mostly after external resets.
244
+ */
226
245
  function syncKeys() {
227
246
  const arrayLength = array.value.length;
228
247
  if (itemKeys.value.length > arrayLength) itemKeys.value.length = arrayLength;
@@ -230,7 +249,7 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
230
249
  }
231
250
  /**
232
251
  * Applies an array update, syncs form dirty/touched state, and triggers validation.
233
- * @param updater Function that modifies the array
252
+ * @param updater Function that modifies the array.
234
253
  */
235
254
  function mutate(updater) {
236
255
  const current = [...array.value];
@@ -242,20 +261,20 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
242
261
  if (validateOn.value.onChange) validate();
243
262
  }
244
263
  /**
245
- * Validates the array field
246
- * @returns Promise that resolves to the validation result
264
+ * Validates the array field.
265
+ * @returns A promise that resolves to the validation result.
247
266
  */
248
267
  async function validate() {
249
- isValidating.value = true;
268
+ beginValidation();
250
269
  try {
251
270
  return await form.validateField(props.path);
252
271
  } finally {
253
- isValidating.value = false;
272
+ endValidation();
254
273
  }
255
274
  }
256
275
  /**
257
- * Appends an item to the array
258
- * @param value The value to append
276
+ * Appends an item to the array.
277
+ * @param value The value to append.
259
278
  */
260
279
  function append(value) {
261
280
  itemKeys.value.push(generateKey());
@@ -264,8 +283,8 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
264
283
  });
265
284
  }
266
285
  /**
267
- * Prepends an item to the array
268
- * @param value The value to prepend
286
+ * Prepends an item to the array.
287
+ * @param value The value to prepend.
269
288
  */
270
289
  function prepend(value) {
271
290
  itemKeys.value.unshift(generateKey());
@@ -275,8 +294,8 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
275
294
  });
276
295
  }
277
296
  /**
278
- * Removes an item from the array
279
- * @param index The index of the item to remove
297
+ * Removes an item from the array.
298
+ * @param index The item index to remove.
280
299
  */
281
300
  function remove(index) {
282
301
  itemKeys.value.splice(index, 1);
@@ -287,9 +306,9 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
287
306
  mutate((current) => current.splice(index, 1));
288
307
  }
289
308
  /**
290
- * Inserts an item at a specific index
291
- * @param index The index to insert at
292
- * @param value The value to insert
309
+ * Inserts an item at a specific index.
310
+ * @param index The insertion index.
311
+ * @param value The value to insert.
293
312
  */
294
313
  function insert(index, value) {
295
314
  itemKeys.value.splice(index, 0, generateKey());
@@ -300,9 +319,9 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
300
319
  mutate((current) => current.splice(index, 0, value));
301
320
  }
302
321
  /**
303
- * Updates an item at a specific index
304
- * @param index The index to update
305
- * @param value The new value
322
+ * Updates an item at a specific index.
323
+ * @param index The item index to update.
324
+ * @param value The new item value.
306
325
  */
307
326
  function update(index, value) {
308
327
  mutate((current) => {
@@ -310,9 +329,9 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
310
329
  });
311
330
  }
312
331
  /**
313
- * Swaps two items in the array
314
- * @param indexA The first index
315
- * @param indexB The second index
332
+ * Swaps two items in the array.
333
+ * @param indexA The first item index.
334
+ * @param indexB The second item index.
316
335
  */
317
336
  function swap(indexA, indexB) {
318
337
  const temporaryKey = itemKeys.value[indexA];
@@ -330,9 +349,9 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
330
349
  });
331
350
  }
332
351
  /**
333
- * Moves an item from one index to another
334
- * @param from The source index
335
- * @param to The destination index
352
+ * Moves an item from one index to another.
353
+ * @param from The source index.
354
+ * @param to The destination index.
336
355
  */
337
356
  function move(from, to) {
338
357
  const [movedItemKey] = itemKeys.value.splice(from, 1);
@@ -403,9 +422,10 @@ var not_field_default = /* @__PURE__ */ defineComponent({
403
422
  const props = __props;
404
423
  const form = useNotFormInstance(props.form);
405
424
  const isValidating = ref(false);
425
+ const validatingCount = ref(0);
406
426
  /** Timer handle for the current pending debounced validation. */
407
427
  const debounceTimer = ref();
408
- /** Merges per-field overrides with the form-wide validation config */
428
+ /** Merges per-field overrides with the form-wide validation config. */
409
429
  const validateOn = computed(() => ({
410
430
  ...form.validateOn,
411
431
  ...props.validateOn
@@ -415,6 +435,16 @@ var not_field_default = /* @__PURE__ */ defineComponent({
415
435
  const isValid = computed(() => errors.value.length === 0);
416
436
  const isTouched = computed(() => form.touchedFields.has(props.path));
417
437
  const isDirty = computed(() => form.dirtyFields.has(props.path));
438
+ /** Starts a local field validation execution. */
439
+ function beginValidation() {
440
+ validatingCount.value++;
441
+ isValidating.value = true;
442
+ }
443
+ /** Ends a local field validation execution. */
444
+ function endValidation() {
445
+ validatingCount.value--;
446
+ if (validatingCount.value === 0) isValidating.value = false;
447
+ }
418
448
  /** Cancels pending debounced validation on blur/unmount to prevent rogue execution. */
419
449
  function clearDebounce() {
420
450
  if (debounceTimer.value === void 0) return;
@@ -426,7 +456,7 @@ var not_field_default = /* @__PURE__ */ defineComponent({
426
456
  if (dequal(value.value, getProperty(form.initialValues, props.path))) form.dirtyFields.delete(props.path);
427
457
  else form.dirtyField(props.path);
428
458
  }
429
- /** Replaces pending validations with a new timer, or runs synchronously if no debounce. */
459
+ /** Replaces pending debounced validation with a new timer, or runs immediately when no debounce is configured. */
430
460
  function scheduleValidation() {
431
461
  if (!props.debounce) {
432
462
  validate();
@@ -440,11 +470,11 @@ var not_field_default = /* @__PURE__ */ defineComponent({
440
470
  * @returns A promise that resolves to the validation result.
441
471
  */
442
472
  async function validate() {
443
- isValidating.value = true;
473
+ beginValidation();
444
474
  try {
445
475
  return await form.validateField(props.path);
446
476
  } finally {
447
- isValidating.value = false;
477
+ endValidation();
448
478
  }
449
479
  }
450
480
  /** Handles the blur event for the field. */
@@ -507,7 +537,7 @@ var not_form_default = /* @__PURE__ */ defineComponent({
507
537
  setup(__props) {
508
538
  provideNotFormInstance(__props.form);
509
539
  return (_ctx, _cache) => {
510
- return openBlock(), createElementBlock("form", null, [renderSlot(_ctx.$slots, "default")]);
540
+ return openBlock(), createElementBlock("form", { onReset: _cache[0] || (_cache[0] = withModifiers(() => {}, ["prevent"])) }, [renderSlot(_ctx.$slots, "default")], 32);
511
541
  };
512
542
  }
513
543
  });
@@ -567,12 +597,6 @@ function useNotForm(config) {
567
597
  const touchedFields = reactive(/* @__PURE__ */ new Set());
568
598
  const dirtyFields = reactive(/* @__PURE__ */ new Set());
569
599
  const isSubmitting = ref(false);
570
- const isValidating = ref(false);
571
- /**
572
- * Tracks concurrent validations to keep `isValidating` true until all finish.
573
- * @internal
574
- */
575
- let validatingCount = 0;
576
600
  const isValid = computed(() => errors.length === 0);
577
601
  const isDirty = computed(() => dirtyFields.size > 0);
578
602
  const isTouched = computed(() => touchedFields.size > 0);
@@ -585,49 +609,77 @@ function useNotForm(config) {
585
609
  }
586
610
  return result;
587
611
  });
612
+ /** Whether one or more validation operations are currently running. */
613
+ const isValidating = ref(false);
614
+ /** Number of validation operations currently in progress. */
615
+ const validatingCount = ref(0);
616
+ /** Identifier of the latest validation cycle. */
617
+ let validationCycle = 0;
618
+ /** Identifier of the latest validation cycle for each field. */
619
+ const fieldValidationCycles = /* @__PURE__ */ new Map();
588
620
  /**
589
- * Increments validation counter and activates `isValidating`.
590
- * @internal
621
+ * Starts a validation operation.
622
+ * @returns The identifier of the new validation cycle.
591
623
  */
592
- function beginValidating() {
593
- validatingCount++;
624
+ function beginValidation() {
625
+ validatingCount.value++;
594
626
  isValidating.value = true;
627
+ return ++validationCycle;
595
628
  }
596
629
  /**
597
- * Decrements validation counter and deactivates `isValidating` when zero.
598
- * @internal
630
+ * Starts a validation operation for a specific field path.
631
+ * @param path The field path to start validation for.
632
+ * @returns The identifier of the new validation cycle.
599
633
  */
600
- function endValidating() {
601
- validatingCount--;
602
- if (validatingCount === 0) isValidating.value = false;
634
+ function beginFieldValidation(path) {
635
+ validatingCount.value++;
636
+ isValidating.value = true;
637
+ const key = String(path);
638
+ const cycle = (fieldValidationCycles.get(key) ?? 0) + 1;
639
+ fieldValidationCycles.set(key, cycle);
640
+ return cycle;
641
+ }
642
+ /**
643
+ * Checks if the validation cycle for a specific field path is current.
644
+ * @param path The field path to check.
645
+ * @param cycle The validation cycle identifier.
646
+ * @returns Whether the cycle is still current.
647
+ */
648
+ function isCurrentFieldValidation(path, cycle) {
649
+ return fieldValidationCycles.get(String(path)) === cycle;
650
+ }
651
+ /** Marks a validation operation as finished. */
652
+ function endValidation() {
653
+ validatingCount.value--;
654
+ if (validatingCount.value === 0) isValidating.value = false;
655
+ }
656
+ /**
657
+ * Checks whether a validation cycle is still the latest cycle.
658
+ * @param cycle The validation cycle identifier.
659
+ * @returns Whether the cycle is still current.
660
+ */
661
+ function isCurrentValidation(cycle) {
662
+ return cycle === validationCycle;
603
663
  }
604
664
  /**
605
665
  * Resolves and executes the standard schema validation.
606
- * @internal
607
666
  * @returns The validation result containing either `value` or `issues`.
608
667
  */
609
668
  function runSchema() {
610
669
  return toValue(config.schema)["~standard"].validate(values);
611
670
  }
612
- /**
613
- * Iterates through deeply nested keys to mark all as touched.
614
- * @internal
615
- */
671
+ /** Iterates through deeply nested keys to mark all as touched. */
616
672
  function touchAllFields() {
617
673
  for (const path of deepKeys(values)) touchedFields.add(path);
618
674
  }
619
675
  /**
620
676
  * Removes a specific field path from the dirty tracking set.
621
- * @internal
622
677
  * @param path The field path to mark as clean.
623
678
  */
624
679
  function unDirtyField(path) {
625
680
  dirtyFields.delete(path);
626
681
  }
627
- /**
628
- * Iterates through deeply nested keys to mark all as dirty.
629
- * @internal
630
- */
682
+ /** Iterates through deeply nested keys to mark all as dirty. */
631
683
  function dirtyAllFields() {
632
684
  for (const path of deepKeys(values)) dirtyFields.add(path);
633
685
  }
@@ -668,7 +720,7 @@ function useNotForm(config) {
668
720
  else errors[existingIndex] = newIssue;
669
721
  }
670
722
  /**
671
- * Sets multiple error issues in the errors array, replacing any existing issues.
723
+ * Sets multiple error issues in the errors array, replacing all existing issues.
672
724
  * @param newIssues The array of issues to set.
673
725
  */
674
726
  function setErrors(newIssues) {
@@ -689,31 +741,38 @@ function useNotForm(config) {
689
741
  }
690
742
  /**
691
743
  * Validates the entire form against the schema, updating errors and returning the result.
744
+ *
745
+ * Only the latest validation cycle is allowed to mutate form validation state.
692
746
  * @returns An object containing either `value` or `issues` based on validation outcome.
693
747
  */
694
748
  async function validate() {
695
- beginValidating();
749
+ const cycle = beginValidation();
696
750
  try {
697
751
  const result = await runSchema();
698
- if (result?.issues) {
699
- setErrors([...result.issues]);
700
- return { issues: result.issues };
752
+ if (!isCurrentValidation(cycle)) return result;
753
+ if (result.issues) {
754
+ const issues = [...result.issues];
755
+ setErrors(issues);
756
+ return { issues };
701
757
  }
702
758
  clearErrors();
703
759
  return { value: result.value };
704
760
  } finally {
705
- endValidating();
761
+ endValidation();
706
762
  }
707
763
  }
708
764
  /**
709
765
  * Validates a specific field against the schema, updating errors and returning the result.
766
+ *
767
+ * Only the latest validation cycle is allowed to mutate form validation state.
710
768
  * @param path The field path to validate.
711
769
  * @returns An object containing either `value` or `issues` based on validation outcome.
712
770
  */
713
771
  async function validateField(path) {
714
- beginValidating();
772
+ const cycle = beginFieldValidation(path);
715
773
  try {
716
774
  const result = await runSchema();
775
+ if (!isCurrentFieldValidation(path, cycle)) return result;
717
776
  const pathSegments = parsePath(path);
718
777
  const staleIndices = [];
719
778
  for (const [index, error] of errors.entries()) if (isIssuePathEqual(error.path, pathSegments)) staleIndices.push(index);
@@ -728,23 +787,33 @@ function useNotForm(config) {
728
787
  }
729
788
  return { value: getProperty(result.value, path) };
730
789
  } finally {
731
- endValidating();
790
+ endValidation();
732
791
  }
733
792
  }
734
793
  /**
735
794
  * Submits the form, validating it and executing the submit handler if provided.
795
+ *
796
+ * Duplicate submissions are ignored while a submission is already in progress.
736
797
  * @param event The submit event.
737
798
  */
738
799
  async function submit(event) {
739
800
  event.preventDefault();
801
+ if (isSubmitting.value) return;
740
802
  isSubmitting.value = true;
803
+ const cycle = beginValidation();
741
804
  try {
742
805
  touchAllFields();
743
806
  dirtyAllFields();
744
- const result = await validate();
745
- if (result?.issues) return;
807
+ const result = await runSchema();
808
+ if (!isCurrentValidation(cycle)) return;
809
+ if (result.issues) {
810
+ setErrors([...result.issues]);
811
+ return;
812
+ }
813
+ clearErrors();
746
814
  if (config.onSubmit) await config.onSubmit(result.value);
747
815
  } finally {
816
+ endValidation();
748
817
  isSubmitting.value = false;
749
818
  }
750
819
  }
@@ -754,6 +823,7 @@ function useNotForm(config) {
754
823
  * @param newErrors The new errors to reset the form with.
755
824
  */
756
825
  function reset(newValues, newErrors) {
826
+ validationCycle++;
757
827
  if (newValues) initialValues = klona(newValues);
758
828
  if (newErrors) initialErrors = klona(newErrors);
759
829
  const freshValues = klona(initialValues);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "notform",
3
3
  "type": "module",
4
- "version": "2.2.3",
4
+ "version": "2.2.5",
5
5
  "private": false,
6
6
  "description": "Vue Forms Without the Friction",
7
7
  "author": "Favour Emeka <favorodera@gmail.com>",
@@ -36,11 +36,11 @@
36
36
  "access": "public"
37
37
  },
38
38
  "engines": {
39
- "node": ">=22.0.0",
39
+ "node": ">=24.0.0",
40
40
  "pnpm": ">=11.0.0"
41
41
  },
42
42
  "peerDependencies": {
43
- "vue": "^3.0.0"
43
+ "vue": "^3.5.0"
44
44
  },
45
45
  "dependencies": {
46
46
  "@standard-schema/spec": "^1.1.0",
@@ -49,17 +49,17 @@
49
49
  "klona": "^2.0.6"
50
50
  },
51
51
  "devDependencies": {
52
- "@favorodera/eslint-config": "^1.1.2",
53
- "@types/node": "^26.2.0",
52
+ "@favorodera/eslint-config": "^1.1.3",
53
+ "@types/node": "^26.5.0",
54
54
  "@vitejs/plugin-vue": "^6.0.8",
55
55
  "@vitest/ui": "^4.1.11",
56
- "@vue/test-utils": "^2.4.11",
57
- "happy-dom": "^20.11.6",
56
+ "@vue/test-utils": "^2.5.0",
57
+ "happy-dom": "^20.14.0",
58
58
  "tsdown": "^0.22.14",
59
- "type-fest": "^5.7.0",
59
+ "type-fest": "^5.9.0",
60
60
  "vite": "^8.2.2",
61
61
  "vitest": "^4.1.11",
62
- "vue": "^3.0.0",
62
+ "vue": "^3.5.0",
63
63
  "vue-tsc": "^3.3.11"
64
64
  },
65
65
  "scripts": {