notform 2.2.2 → 2.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,16 +1,16 @@
1
1
  <div align="center">
2
- <h1>notform</h1>
3
- <p><strong>Vue Forms Without the Friction</strong></p>
2
+ <h1><code>notform</code></h1>
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&color=blue" 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
- <br>
11
+ `notform` is the core NotForm package for Vue 3.
12
12
 
13
- `notform` is the core package of the NotForm ecosystem. It provides type-safe form validation and state management for Vue 3 applications with minimal boilerplate. Built with TypeScript from the ground up, it offers a composable API that integrates perfectly with Vue 3's Composition API.
13
+ It provides reactive form state, schema-based validation, field state, submission handling, and renderless components that work with any UI you choose.
14
14
 
15
15
  ## Installation
16
16
 
@@ -18,18 +18,13 @@
18
18
  pnpm add notform
19
19
  ```
20
20
 
21
- ## How It Works
21
+ Install a Standard Schema-compatible validator separately, for example:
22
22
 
23
- NotForm follows a **composable-first** approach. Forms are managed through the `useNotForm` composable which handles validation, state, and submission logic in a type-safe manner.
24
-
25
- Each form consists of:
26
-
27
- - **A schema** — Defined using any validation library that supports [Standard Schema](https://standard-schema.dev/) (Zod, Yup, etc.)
28
- - **Form state** — Managed reactively with full TypeScript support
29
- - **Validation** — Automatic validation based on your schema with error messages
30
- - **Submission handling** — Built-in submission lifecycle with loading states
23
+ ```bash
24
+ pnpm add zod
25
+ ```
31
26
 
32
- ### Basic Usage
27
+ ## Basic Usage
33
28
 
34
29
  ```vue
35
30
  <script setup lang="ts">
@@ -37,13 +32,17 @@ import { NotField, NotForm, NotMessage, useNotForm } from 'notform'
37
32
  import { z } from 'zod'
38
33
 
39
34
  const schema = z.object({
40
- email: z.email('Invalid email'),
41
- 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'),
42
37
  })
43
38
 
44
39
  const form = useNotForm({
40
+ initialValues: {
41
+ email: '',
42
+ name: '',
43
+ },
45
44
  onSubmit(values) {
46
- console.log('Form:', values)
45
+ console.log('Submitted:', values)
47
46
  },
48
47
  schema,
49
48
  })
@@ -51,42 +50,48 @@ const form = useNotForm({
51
50
 
52
51
  <template>
53
52
  <NotForm
54
- :form
55
- @submit="form.submit"
53
+ :form="form"
54
+ @submit.prevent="form.submit"
56
55
  @reset="form.reset()"
57
56
  >
58
57
  <NotField
59
- v-slot="{events,path}"
58
+ v-slot="{ events, path }"
60
59
  path="name"
61
60
  >
62
- <label :for="path">
63
- Name
61
+ <div>
62
+ <label :for="path">Name</label>
63
+
64
64
  <input
65
- v-bind="events"
66
65
  :id="path"
67
66
  v-model="form.values.name"
67
+ v-bind="events"
68
+ name="name"
68
69
  type="text"
70
+ autocomplete="name"
69
71
  >
70
- </label>
71
72
 
72
- <NotMessage :path="path" />
73
+ <NotMessage :path="path" />
74
+ </div>
73
75
  </NotField>
74
76
 
75
77
  <NotField
76
- v-slot="{events,path}"
78
+ v-slot="{ events, path }"
77
79
  path="email"
78
80
  >
79
- <label :for="path">
80
- Email
81
+ <div>
82
+ <label :for="path">Email address</label>
83
+
81
84
  <input
82
- v-bind="events"
83
85
  :id="path"
84
86
  v-model="form.values.email"
87
+ v-bind="events"
88
+ name="email"
85
89
  type="email"
90
+ autocomplete="email"
86
91
  >
87
- </label>
88
92
 
89
- <NotMessage :path="path" />
93
+ <NotMessage :path="path" />
94
+ </div>
90
95
  </NotField>
91
96
 
92
97
  <button type="submit">
@@ -94,36 +99,87 @@ const form = useNotForm({
94
99
  </button>
95
100
 
96
101
  <button type="reset">
97
- Reset Form
102
+ Reset
98
103
  </button>
99
104
  </NotForm>
100
105
  </template>
101
106
  ```
102
107
 
103
- ### Validation Libraries
108
+ ## API
104
109
 
105
- NotForm works with any validation library that implements the Standard Schema interface:
110
+ The core package exports:
106
111
 
107
- - **Zod** — TypeScript-first schema validation
108
- - **Yup** — JavaScript schema builder
109
- - **Valibot** — Modular and type-safe schema validation
110
- - And any other Standard Schema-compatible library
112
+ - `useNotForm`
113
+ - `NotForm`
114
+ - `NotField`
115
+ - `NotMessage`
116
+ - `NotArrayField`
111
117
 
112
- ## Features
118
+ The form instance manages values, errors, touched and dirty state, validation, submission state, and reset behavior.
113
119
 
114
- - **Type-safe** — Full TypeScript support with inferred types from your schema
115
- - **Composable** — Use with Vue 3 Composition API for flexible form management
116
- - **Lightweight** — Tiny footprint with tree-shaking support
117
- - **Flexible** — Works with any Standard Schema-compatible validation library
118
- - **Array fields** — Built-in support for dynamic array fields with add/remove operations
119
- - **Minimal boilerplate** — Get started quickly with simple, intuitive APIs
120
+ ## Array Fields
120
121
 
121
- ## Documentation
122
+ `NotArrayField` provides renderless operations for dynamic arrays while preserving stable item keys during reordering.
122
123
 
123
- For detailed guides, API reference, and examples, visit:
124
- **[notformdocs.vercel.app](https://notformdocs.vercel.app/)**
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"
132
+ >
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"
141
+ >
142
+ <NotMessage :path="path" />
143
+ </NotField>
125
144
 
126
- ## License
145
+ <button type="button" @click="remove(index)">
146
+ Remove
147
+ </button>
148
+ </div>
149
+
150
+ <button type="button" @click="append('')">
151
+ Add tag
152
+ </button>
153
+ </NotArrayField>
154
+ ```
155
+
156
+ ## Type Safety
157
+
158
+ Field paths and related APIs are inferred from your schema, including nested paths.
159
+
160
+ ```ts
161
+ form.setValue('email', 'jane@example.com')
162
+ await form.validateField('email')
163
+ const errors = form.getFieldErrors('email')
164
+ ```
127
165
 
128
- [MIT](../../LICENSE) &copy; [Favour Emeka](https://github.com/favorodera)
166
+ ## Requirements
167
+
168
+ - Vue 3
169
+ - Node.js 24 or later for development in this repository
170
+ - A Standard Schema-compatible validator
171
+
172
+ ## Development
173
+
174
+ From the repository root:
175
+
176
+ ```bash
177
+ pnpm install
178
+ pnpm --filter notform test
179
+ pnpm --filter notform typecheck
180
+ pnpm --filter notform build
181
+ ```
182
+
183
+ ## License
129
184
 
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,28 +242,29 @@ 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
- submit: (event: Event) => Promise<void>;
248
+ submit: (event: SubmitEvent) => Promise<void>;
219
249
  /**
220
250
  * Resets the form to its initial state, or to new values and errors if provided.
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
@@ -184,13 +184,14 @@ 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
- let keyCounter = 0;
189
+ const keyCounter = ref(0);
189
190
  /** Stable keys per item that survive reorders, removals, and inserts. */
190
191
  const itemKeys = ref((() => {
191
192
  const initial = getProperty(form.values, props.path);
192
193
  const length = Array.isArray(initial) ? initial.length : 0;
193
- return Array.from({ length }, () => `${props.path}-${keyCounter++}`);
194
+ return Array.from({ length }, () => `${props.path}-${keyCounter.value++}`);
194
195
  })());
195
196
  const validateOn = computed(() => ({
196
197
  onChange: props.validateOn?.onChange ?? form.validateOn.onChange,
@@ -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
- return `${props.path}-${keyCounter++}`;
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,37 +261,41 @@ 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());
262
- mutate((current) => current.push(value));
281
+ mutate((current) => {
282
+ current.push(value);
283
+ });
263
284
  }
264
285
  /**
265
- * Prepends an item to the array
266
- * @param value The value to prepend
286
+ * Prepends an item to the array.
287
+ * @param value The value to prepend.
267
288
  */
268
289
  function prepend(value) {
269
290
  itemKeys.value.unshift(generateKey());
270
291
  remapArrayFieldState(form, props.path, (previousIndex) => previousIndex + 1);
271
- mutate((current) => current.unshift(value));
292
+ mutate((current) => {
293
+ current.unshift(value);
294
+ });
272
295
  }
273
296
  /**
274
- * Removes an item from the array
275
- * @param index The index of the item to remove
297
+ * Removes an item from the array.
298
+ * @param index The item index to remove.
276
299
  */
277
300
  function remove(index) {
278
301
  itemKeys.value.splice(index, 1);
@@ -283,9 +306,9 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
283
306
  mutate((current) => current.splice(index, 1));
284
307
  }
285
308
  /**
286
- * Inserts an item at a specific index
287
- * @param index The index to insert at
288
- * @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.
289
312
  */
290
313
  function insert(index, value) {
291
314
  itemKeys.value.splice(index, 0, generateKey());
@@ -296,9 +319,9 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
296
319
  mutate((current) => current.splice(index, 0, value));
297
320
  }
298
321
  /**
299
- * Updates an item at a specific index
300
- * @param index The index to update
301
- * @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.
302
325
  */
303
326
  function update(index, value) {
304
327
  mutate((current) => {
@@ -306,9 +329,9 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
306
329
  });
307
330
  }
308
331
  /**
309
- * Swaps two items in the array
310
- * @param indexA The first index
311
- * @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.
312
335
  */
313
336
  function swap(indexA, indexB) {
314
337
  const temporaryKey = itemKeys.value[indexA];
@@ -326,9 +349,9 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
326
349
  });
327
350
  }
328
351
  /**
329
- * Moves an item from one index to another
330
- * @param from The source index
331
- * @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.
332
355
  */
333
356
  function move(from, to) {
334
357
  const [movedItemKey] = itemKeys.value.splice(from, 1);
@@ -399,9 +422,10 @@ var not_field_default = /* @__PURE__ */ defineComponent({
399
422
  const props = __props;
400
423
  const form = useNotFormInstance(props.form);
401
424
  const isValidating = ref(false);
425
+ const validatingCount = ref(0);
402
426
  /** Timer handle for the current pending debounced validation. */
403
- let debounceTimer;
404
- /** Merges per-field overrides with the form-wide validation config */
427
+ const debounceTimer = ref();
428
+ /** Merges per-field overrides with the form-wide validation config. */
405
429
  const validateOn = computed(() => ({
406
430
  ...form.validateOn,
407
431
  ...props.validateOn
@@ -411,37 +435,46 @@ var not_field_default = /* @__PURE__ */ defineComponent({
411
435
  const isValid = computed(() => errors.value.length === 0);
412
436
  const isTouched = computed(() => form.touchedFields.has(props.path));
413
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
+ }
414
448
  /** Cancels pending debounced validation on blur/unmount to prevent rogue execution. */
415
449
  function clearDebounce() {
416
- if (debounceTimer !== void 0) {
417
- clearTimeout(debounceTimer);
418
- debounceTimer = void 0;
419
- }
450
+ if (debounceTimer.value === void 0) return;
451
+ clearTimeout(debounceTimer.value);
452
+ debounceTimer.value = void 0;
420
453
  }
421
454
  /** Compares current value against baseline to sync dirty state tracking. */
422
455
  function updateDirty() {
423
456
  if (dequal(value.value, getProperty(form.initialValues, props.path))) form.dirtyFields.delete(props.path);
424
457
  else form.dirtyField(props.path);
425
458
  }
426
- /** 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. */
427
460
  function scheduleValidation() {
428
461
  if (!props.debounce) {
429
462
  validate();
430
463
  return;
431
464
  }
432
465
  clearDebounce();
433
- debounceTimer = setTimeout(validate, props.debounce);
466
+ debounceTimer.value = setTimeout(validate, props.debounce);
434
467
  }
435
468
  /**
436
469
  * Validates the field and returns the validation result.
437
470
  * @returns A promise that resolves to the validation result.
438
471
  */
439
472
  async function validate() {
440
- isValidating.value = true;
473
+ beginValidation();
441
474
  try {
442
475
  return await form.validateField(props.path);
443
476
  } finally {
444
- isValidating.value = false;
477
+ endValidation();
445
478
  }
446
479
  }
447
480
  /** Handles the blur event for the field. */
@@ -564,66 +597,89 @@ function useNotForm(config) {
564
597
  const touchedFields = reactive(/* @__PURE__ */ new Set());
565
598
  const dirtyFields = reactive(/* @__PURE__ */ new Set());
566
599
  const isSubmitting = ref(false);
567
- const isValidating = ref(false);
568
- /**
569
- * Tracks concurrent validations to keep `isValidating` true until all finish.
570
- * @internal
571
- */
572
- let validatingCount = 0;
573
600
  const isValid = computed(() => errors.length === 0);
574
601
  const isDirty = computed(() => dirtyFields.size > 0);
575
602
  const isTouched = computed(() => touchedFields.size > 0);
576
603
  const errorsMap = computed(() => {
577
604
  const result = {};
578
- for (const issue of errors) if (issue.path) {
605
+ for (const issue of errors) {
606
+ if (!issue.path) continue;
579
607
  const path = issue.path.map((element) => normalizeSegment(element)).join(".");
580
- if (path && !result[path]) result[path] = issue.message;
608
+ if (path && !Object.hasOwn(result, path)) result[path] = issue.message;
581
609
  }
582
610
  return result;
583
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();
584
620
  /**
585
- * Increments validation counter and activates `isValidating`.
586
- * @internal
621
+ * Starts a validation operation.
622
+ * @returns The identifier of the new validation cycle.
587
623
  */
588
- function beginValidating() {
589
- validatingCount++;
624
+ function beginValidation() {
625
+ validatingCount.value++;
590
626
  isValidating.value = true;
627
+ return ++validationCycle;
591
628
  }
592
629
  /**
593
- * Decrements validation counter and deactivates `isValidating` when zero.
594
- * @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.
595
633
  */
596
- function endValidating() {
597
- validatingCount--;
598
- 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;
599
663
  }
600
664
  /**
601
665
  * Resolves and executes the standard schema validation.
602
- * @internal
603
666
  * @returns The validation result containing either `value` or `issues`.
604
667
  */
605
668
  function runSchema() {
606
669
  return toValue(config.schema)["~standard"].validate(values);
607
670
  }
608
- /**
609
- * Iterates through deeply nested keys to mark all as touched.
610
- * @internal
611
- */
671
+ /** Iterates through deeply nested keys to mark all as touched. */
612
672
  function touchAllFields() {
613
673
  for (const path of deepKeys(values)) touchedFields.add(path);
614
674
  }
615
675
  /**
616
676
  * Removes a specific field path from the dirty tracking set.
617
- * @internal
618
677
  * @param path The field path to mark as clean.
619
678
  */
620
679
  function unDirtyField(path) {
621
680
  dirtyFields.delete(path);
622
681
  }
623
- /**
624
- * Iterates through deeply nested keys to mark all as dirty.
625
- * @internal
626
- */
682
+ /** Iterates through deeply nested keys to mark all as dirty. */
627
683
  function dirtyAllFields() {
628
684
  for (const path of deepKeys(values)) dirtyFields.add(path);
629
685
  }
@@ -664,7 +720,7 @@ function useNotForm(config) {
664
720
  else errors[existingIndex] = newIssue;
665
721
  }
666
722
  /**
667
- * 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.
668
724
  * @param newIssues The array of issues to set.
669
725
  */
670
726
  function setErrors(newIssues) {
@@ -672,7 +728,7 @@ function useNotForm(config) {
672
728
  }
673
729
  /** Clears all error issues from the errors array. */
674
730
  function clearErrors() {
675
- errors.splice(0);
731
+ errors.length = 0;
676
732
  }
677
733
  /**
678
734
  * Retrieves all error issues associated with a specific field path.
@@ -685,31 +741,38 @@ function useNotForm(config) {
685
741
  }
686
742
  /**
687
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.
688
746
  * @returns An object containing either `value` or `issues` based on validation outcome.
689
747
  */
690
748
  async function validate() {
691
- beginValidating();
749
+ const cycle = beginValidation();
692
750
  try {
693
751
  const result = await runSchema();
694
- if (result?.issues) {
695
- setErrors([...result.issues]);
696
- 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 };
697
757
  }
698
758
  clearErrors();
699
759
  return { value: result.value };
700
760
  } finally {
701
- endValidating();
761
+ endValidation();
702
762
  }
703
763
  }
704
764
  /**
705
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.
706
768
  * @param path The field path to validate.
707
769
  * @returns An object containing either `value` or `issues` based on validation outcome.
708
770
  */
709
771
  async function validateField(path) {
710
- beginValidating();
772
+ const cycle = beginFieldValidation(path);
711
773
  try {
712
774
  const result = await runSchema();
775
+ if (!isCurrentFieldValidation(path, cycle)) return result;
713
776
  const pathSegments = parsePath(path);
714
777
  const staleIndices = [];
715
778
  for (const [index, error] of errors.entries()) if (isIssuePathEqual(error.path, pathSegments)) staleIndices.push(index);
@@ -724,30 +787,33 @@ function useNotForm(config) {
724
787
  }
725
788
  return { value: getProperty(result.value, path) };
726
789
  } finally {
727
- endValidating();
790
+ endValidation();
728
791
  }
729
792
  }
730
793
  /**
731
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.
732
797
  * @param event The submit event.
733
798
  */
734
799
  async function submit(event) {
800
+ event.preventDefault();
801
+ if (isSubmitting.value) return;
735
802
  isSubmitting.value = true;
803
+ const cycle = beginValidation();
736
804
  try {
737
805
  touchAllFields();
738
806
  dirtyAllFields();
739
- const result = await validate();
740
- if (result?.issues) {
741
- event.preventDefault();
807
+ const result = await runSchema();
808
+ if (!isCurrentValidation(cycle)) return;
809
+ if (result.issues) {
810
+ setErrors([...result.issues]);
742
811
  return;
743
812
  }
744
- if (config.onSubmit) {
745
- event.preventDefault();
746
- await config.onSubmit(result.value);
747
- }
748
- } catch {
749
- event.preventDefault();
813
+ clearErrors();
814
+ if (config.onSubmit) await config.onSubmit(result.value);
750
815
  } finally {
816
+ endValidation();
751
817
  isSubmitting.value = false;
752
818
  }
753
819
  }
@@ -757,6 +823,7 @@ function useNotForm(config) {
757
823
  * @param newErrors The new errors to reset the form with.
758
824
  */
759
825
  function reset(newValues, newErrors) {
826
+ validationCycle++;
760
827
  if (newValues) initialValues = klona(newValues);
761
828
  if (newErrors) initialErrors = klona(newErrors);
762
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.2",
4
+ "version": "2.2.4",
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,18 +49,18 @@
49
49
  "klona": "^2.0.6"
50
50
  },
51
51
  "devDependencies": {
52
- "@favorodera/eslint-config": "^1.0.4",
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
- "@vitest/ui": "^4.1.10",
56
- "@vue/test-utils": "^2.4.11",
57
- "happy-dom": "^20.11.2",
55
+ "@vitest/ui": "^4.1.11",
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",
60
- "vite": "^8.2.1",
61
- "vitest": "^4.1.10",
62
- "vue": "^3.0.0",
63
- "vue-tsc": "^3.3.9"
59
+ "type-fest": "^5.9.0",
60
+ "vite": "^8.2.2",
61
+ "vitest": "^4.1.11",
62
+ "vue": "^3.5.0",
63
+ "vue-tsc": "^3.3.11"
64
64
  },
65
65
  "scripts": {
66
66
  "build": "tsdown",