notform 2.1.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +99 -6
  2. package/dist/index.d.ts +268 -255
  3. package/dist/index.js +578 -523
  4. package/package.json +28 -25
package/dist/index.js CHANGED
@@ -1,263 +1,13 @@
1
- import { computed, createBlock, createCommentVNode, createElementBlock, createTextVNode, defineComponent, guardReactiveProps, inject, markRaw, mergeProps, nextTick, normalizeProps, onMounted, onUnmounted, openBlock, provide, reactive, ref, renderSlot, resolveDynamicComponent, toDisplayString, toValue, unref, useAttrs, watch, withCtx } from "vue";
2
- import { klona } from "klona/full";
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";
3
2
  import { dequal } from "dequal";
4
3
  import { deepKeys, deleteProperty, getProperty, hasProperty, parsePath, setProperty } from "dot-prop";
5
- //#region src/utils/form-utils.ts
6
- /**
7
- * Normalizes a validation path segment into a standard property key.
8
- * @param segment The path segment to normalize.
9
- * @returns The normalized key.
10
- */
11
- function normalizeSegment(segment) {
12
- if (typeof segment === "object" && segment !== null && "key" in segment) return segment.key;
13
- return segment;
14
- }
15
- /**
16
- * Checks if a validation issue path matches a target field path.
17
- * @param issuePath The path array from the validation issue.
18
- * @param targetPath The normalized path to compare against.
19
- * @returns True if the paths are equivalent.
20
- */
21
- function isIssuePathEqual(issuePath, targetPath) {
22
- if (!issuePath) return false;
23
- if (issuePath.length !== targetPath.length) return false;
24
- return issuePath.every((segment, index) => {
25
- const normalizedSegment = normalizeSegment(segment);
26
- const targetSegment = targetPath[index];
27
- if (typeof normalizedSegment === "number" || typeof targetSegment === "number") return Number(normalizedSegment) === Number(targetSegment);
28
- return normalizedSegment === targetSegment;
29
- });
30
- }
31
- //#endregion
32
- //#region src/composables/use-not-form.ts
33
- function useNotForm(config) {
34
- /** Runs the schema against the current values and returns the raw result. */
35
- const runSchema = () => {
36
- return toValue(config.schema)["~standard"].validate(values);
37
- };
38
- /**
39
- * Marks all current leaf paths as touched.
40
- * Called internally on submit so all field errors surface at once.
41
- */
42
- const touchAllFields = () => {
43
- deepKeys(values).forEach((path) => touchedFields.add(path));
44
- };
45
- /**
46
- * Marks all current leaf paths as dirty.
47
- * Called internally on submit so required-field errors are not suppressed.
48
- */
49
- const dirtyAllFields = () => {
50
- deepKeys(values).forEach((path) => dirtyFields.add(path));
51
- };
52
- /** Removes a path from the dirty set without exposing the operation publicly. */
53
- const unDirtyField = (path) => {
54
- dirtyFields.delete(path);
55
- };
56
- /**
57
- * Mutable so `reset()` can replace the reference when new values are provided.
58
- * Always deep-cloned to prevent external mutation from affecting the baseline.
59
- *
60
- * These are intentionally `let` — `reset()` replaces them, and the instance
61
- * exposes them via getters so consumers always read the current snapshot.
62
- * The `readonly` modifier on the type prevents external assignment while still
63
- * allowing the getter to return the latest value after a reset.
64
- */
65
- let initialValues = klona(config.initialValues ?? {});
66
- let initialErrors = klona(config.initialErrors ?? []);
67
- const validateOn = {
68
- onBlur: config.validateOn?.onBlur ?? true,
69
- onChange: config.validateOn?.onChange ?? true,
70
- onInput: config.validateOn?.onInput ?? true,
71
- onMount: config.validateOn?.onMount ?? false,
72
- onFocus: config.validateOn?.onFocus ?? false
73
- };
74
- const validationMode = {
75
- eager: config.validationMode?.eager ?? true,
76
- lazy: config.validationMode?.lazy ?? false
77
- };
78
- /**
79
- * Deeply reactive object — access directly as `form.values.email`.
80
- * Using `reactive()` instead of `ref()` keeps behaviour consistent
81
- * across components, composables, and Pinia stores (which auto-unwrap refs).
82
- */
83
- const values = reactive(klona(initialValues));
84
- /**
85
- * Reactive array mutated in-place to preserve reactivity.
86
- * Using `reactive()` instead of `ref()` prevents Pinia from unwrapping
87
- * the array and losing its reactive proxy.
88
- */
89
- const errors = reactive([...initialErrors]);
90
- const isSubmitting = ref(false);
91
- const isValidating = ref(false);
92
- /**
93
- * Counter-based validation tracking.
94
- *
95
- * A boolean flag flips to `false` as soon as the first concurrent
96
- * validation resolves, even if others are still running.
97
- * A counter fixes this: `isValidating` stays `true` until every
98
- * in-flight call has settled.
99
- */
100
- let validatingCount = 0;
101
- /** Increments the validation counter and sets `isValidating` to true. */
102
- const beginValidating = () => {
103
- validatingCount++;
104
- isValidating.value = true;
105
- };
106
- /** Decrements the validation counter and sets `isValidating` to false if the counter reaches zero. */
107
- const endValidating = () => {
108
- validatingCount--;
109
- if (validatingCount === 0) isValidating.value = false;
110
- };
111
- /**
112
- * Reactive Sets using `reactive()` for the same Pinia-compatibility reason
113
- * as `errors` above — `ref(new Set())` would be unwrapped to a plain Set.
114
- */
115
- const touchedFields = reactive(/* @__PURE__ */ new Set());
116
- const dirtyFields = reactive(/* @__PURE__ */ new Set());
117
- const isValid = computed(() => errors.length === 0);
118
- const isDirty = computed(() => dirtyFields.size > 0);
119
- const isTouched = computed(() => touchedFields.size > 0);
120
- const errorsMap = computed(() => {
121
- return errors.reduce((errorsByPath, issue) => {
122
- if (!issue.path) return errorsByPath;
123
- const path = issue.path.map(normalizeSegment).join(".");
124
- if (path && !errorsByPath[path]) errorsByPath[path] = issue.message;
125
- return errorsByPath;
126
- }, {});
127
- });
128
- const setValue = (path, value) => {
129
- setProperty(values, path, value);
130
- if (dequal(value, getProperty(initialValues, path))) unDirtyField(path);
131
- else dirtyField(path);
132
- };
133
- const touchField = (path) => {
134
- touchedFields.add(path);
135
- };
136
- const dirtyField = (path) => {
137
- dirtyFields.add(path);
138
- };
139
- const setError = (newIssue) => {
140
- const newPath = newIssue.path?.map(normalizeSegment).join(".");
141
- const existingIndex = errors.findIndex((error) => error.path?.map(normalizeSegment).join(".") === newPath);
142
- if (existingIndex !== -1) errors.splice(existingIndex, 1, newIssue);
143
- else errors.push(newIssue);
144
- };
145
- const setErrors = (newIssues) => {
146
- errors.splice(0, errors.length, ...newIssues);
147
- };
148
- const clearErrors = () => {
149
- errors.splice(0, errors.length);
150
- };
151
- const getFieldErrors = (path) => {
152
- const pathSegments = parsePath(path);
153
- return errors.filter((error) => isIssuePathEqual(error.path, pathSegments));
154
- };
155
- const validate = async () => {
156
- beginValidating();
157
- try {
158
- const result = await runSchema();
159
- if (result?.issues) {
160
- setErrors([...result.issues]);
161
- return { issues: result.issues };
162
- }
163
- clearErrors();
164
- return { value: result.value };
165
- } finally {
166
- endValidating();
167
- }
168
- };
169
- const validateField = async (path) => {
170
- beginValidating();
171
- try {
172
- const result = await runSchema();
173
- const pathSegments = parsePath(path);
174
- const staleIndices = errors.reduce((indices, error, index) => {
175
- if (isIssuePathEqual(error.path, pathSegments)) indices.push(index);
176
- return indices;
177
- }, []);
178
- for (let index = staleIndices.length - 1; index >= 0; index--) errors.splice(staleIndices[index], 1);
179
- if (result?.issues) {
180
- const fieldIssues = result.issues.filter((issue) => isIssuePathEqual(issue.path, pathSegments));
181
- if (fieldIssues.length > 0) {
182
- errors.push(...fieldIssues);
183
- return { issues: fieldIssues };
184
- }
185
- return { value: getProperty(values, path) };
186
- }
187
- return { value: getProperty(result.value, path) };
188
- } finally {
189
- endValidating();
190
- }
191
- };
192
- const submit = async (event) => {
193
- isSubmitting.value = true;
194
- try {
195
- touchAllFields();
196
- dirtyAllFields();
197
- const result = await validate();
198
- if (result?.issues) {
199
- event.preventDefault();
200
- return;
201
- }
202
- if (config.onSubmit) {
203
- event.preventDefault();
204
- await config.onSubmit(result.value);
205
- }
206
- } catch {
207
- event.preventDefault();
208
- } finally {
209
- isSubmitting.value = false;
210
- }
211
- };
212
- const reset = (newValues, newErrors) => {
213
- if (newValues) initialValues = klona(newValues);
214
- if (newErrors) initialErrors = klona(newErrors);
215
- const freshValues = klona(initialValues);
216
- const current = values;
217
- for (const key of Object.keys(current)) if (!hasProperty(freshValues, key)) deleteProperty(values, key);
218
- for (const key of Object.keys(freshValues)) setProperty(values, key, getProperty(freshValues, key));
219
- errors.splice(0, errors.length, ...klona(initialErrors));
220
- touchedFields.clear();
221
- dirtyFields.clear();
222
- };
223
- return markRaw({
224
- get initialValues() {
225
- return initialValues;
226
- },
227
- get initialErrors() {
228
- return initialErrors;
229
- },
230
- validateOn,
231
- validationMode,
232
- values,
233
- setValue,
234
- touchedFields,
235
- touchField,
236
- isTouched,
237
- dirtyFields,
238
- dirtyField,
239
- isDirty,
240
- errors,
241
- errorsMap,
242
- setError,
243
- setErrors,
244
- clearErrors,
245
- getFieldErrors,
246
- isValidating,
247
- validate,
248
- validateField,
249
- isValid,
250
- isSubmitting,
251
- submit,
252
- reset
253
- });
254
- }
255
- //#endregion
4
+ import { klona } from "klona/full";
256
5
  //#region src/utils/instance-utils.ts
257
6
  /** The injection key for the NotForm instance. */
258
7
  const NOT_FORM_INSTANCE_KEY = Symbol("notform:instance");
259
8
  /**
260
9
  * Provides the NotForm instance to the component tree.
10
+ * @template TSchema The validation object schema.
261
11
  * @param instance The NotForm instance to provide.
262
12
  */
263
13
  function provideNotFormInstance(instance) {
@@ -265,6 +15,7 @@ function provideNotFormInstance(instance) {
265
15
  }
266
16
  /**
267
17
  * Retrieves the NotForm instance from the component tree.
18
+ * @template TSchema The form schema.
268
19
  * @param explicitInstance Optional explicit instance to use.
269
20
  * @returns The NotForm instance.
270
21
  */
@@ -278,31 +29,18 @@ function useNotFormInstance(explicitInstance) {
278
29
  return instance;
279
30
  }
280
31
  //#endregion
281
- //#region src/components/not-form.vue
282
- var not_form_default = /* @__PURE__ */ defineComponent({
283
- __name: "not-form",
284
- props: { form: {
285
- type: null,
286
- required: true
287
- } },
288
- setup(__props) {
289
- const attributes = useAttrs();
290
- provideNotFormInstance(__props.form);
291
- return (_ctx, _cache) => {
292
- return openBlock(), createElementBlock("form", normalizeProps(guardReactiveProps(unref(attributes))), [renderSlot(_ctx.$slots, "default")], 16);
293
- };
294
- }
295
- });
296
- //#endregion
297
- //#region src/components/not-field.vue
298
- var not_field_default = /* @__PURE__ */ defineComponent({
299
- inheritAttrs: false,
300
- __name: "not-field",
32
+ //#region src/components/not-array-field.vue
33
+ var not_array_field_default = /* @__PURE__ */ defineComponent({
34
+ __name: "not-array-field",
301
35
  props: {
302
36
  path: {
303
37
  type: String,
304
38
  required: true
305
39
  },
40
+ itemSchema: {
41
+ type: null,
42
+ required: false
43
+ },
306
44
  form: {
307
45
  type: null,
308
46
  required: false
@@ -310,167 +48,187 @@ var not_field_default = /* @__PURE__ */ defineComponent({
310
48
  validateOn: {
311
49
  type: Object,
312
50
  required: false
313
- },
314
- debounce: {
315
- type: Number,
316
- required: false
317
51
  }
318
52
  },
319
53
  setup(__props) {
320
54
  const props = __props;
321
55
  const form = useNotFormInstance(props.form);
56
+ const isValidating = ref(false);
57
+ /** Counter to ensure absolute uniqueness for generated keys. */
58
+ let keyCounter = 0;
59
+ /** Stable keys per item that survive reorders, removals, and inserts. */
60
+ const itemKeys = ref((() => {
61
+ const initial = getProperty(form.values, props.path);
62
+ const length = Array.isArray(initial) ? initial.length : 0;
63
+ return Array.from({ length }, () => `${props.path}-${keyCounter++}`);
64
+ })());
322
65
  const validateOn = computed(() => ({
323
- ...form.validateOn,
324
- ...props.validateOn
66
+ onChange: props.validateOn?.onChange ?? form.validateOn.onChange,
67
+ onMount: props.validateOn?.onMount ?? form.validateOn.onMount
325
68
  }));
326
- const isValidating = ref(false);
327
- /** Timer handle for the current pending debounced validation, if any. */
328
- let debounceTimer;
329
- /**
330
- * Cancels any pending debounced validation without running it.
331
- * Called on blur (so blur's own immediate validation takes over) and on unmount
332
- * (to prevent a timer from firing after the component is gone).
333
- */
334
- const clearDebounce = () => {
335
- if (debounceTimer !== void 0) {
336
- clearTimeout(debounceTimer);
337
- debounceTimer = void 0;
338
- }
339
- };
340
- /**
341
- * Schedules a validation run, respecting the field's `debounce` prop.
342
- *
343
- * - If `debounce` is `0` or omitted, validation runs synchronously.
344
- * - Otherwise, any pending timer is cancelled and a new one is started.
345
- * Only the final call within the window actually validates — useful for
346
- * async checks (availability lookups, server-side rules) where firing on
347
- * every keystroke would be wasteful.
348
- */
349
- const scheduleValidation = () => {
350
- if (!props.debounce) {
351
- validate();
352
- return;
353
- }
354
- clearDebounce();
355
- debounceTimer = setTimeout(validate, props.debounce);
356
- };
357
- const value = computed(() => getProperty(form.values, props.path));
69
+ const array = computed(() => {
70
+ const value = getProperty(form.values, props.path);
71
+ return Array.isArray(value) ? value : [];
72
+ });
73
+ const items = computed(() => {
74
+ return array.value.map((_, index) => ({
75
+ index,
76
+ key: itemKeys.value[index] ?? `${props.path}-fallback-${index}`,
77
+ path: `${props.path}.${index}`
78
+ }));
79
+ });
358
80
  const errors = computed(() => form.getFieldErrors(props.path));
359
81
  const isValid = computed(() => errors.value.length === 0);
360
- const isTouched = computed(() => form.touchedFields.has(props.path));
361
- const isDirty = computed(() => form.dirtyFields.has(props.path));
82
+ const isTouched = computed(() => {
83
+ return form.touchedFields.has(props.path) || [...form.touchedFields].some((path) => path.startsWith(`${props.path}.`));
84
+ });
85
+ const isDirty = computed(() => {
86
+ return form.dirtyFields.has(props.path) || [...form.dirtyFields].some((path) => path.startsWith(`${props.path}.`));
87
+ });
362
88
  /**
363
- * Syncs dirty state on input or change.
364
- * Reads the already-updated value (v-model writes before the event fires)
365
- * and compares it against the field's initial value.
89
+ * Generates a unique key string using the field path and counter.
90
+ * @returns A unique key string
366
91
  */
367
- const updateDirty = () => {
368
- if (dequal(value.value, getProperty(form.initialValues, props.path))) form.dirtyFields.delete(props.path);
369
- else form.dirtyField(props.path);
370
- };
371
- const validate = async () => {
372
- isValidating.value = true;
373
- try {
374
- return await form.validateField(props.path);
375
- } finally {
92
+ function generateKey() {
93
+ return `${props.path}-${keyCounter++}`;
94
+ }
95
+ /** Re-aligns itemKeys with current array length (used mostly after external resets) */
96
+ function syncKeys() {
97
+ const arrayLength = array.value.length;
98
+ if (itemKeys.value.length > arrayLength) itemKeys.value.length = arrayLength;
99
+ else while (itemKeys.value.length < arrayLength) itemKeys.value.push(generateKey());
100
+ }
101
+ /**
102
+ * Applies an array update, syncs form dirty/touched state, and triggers validation.
103
+ * @param updater Function that modifies the array
104
+ */
105
+ function mutate(updater) {
106
+ const current = [...array.value];
107
+ updater(current);
108
+ setProperty(form.values, props.path, current);
109
+ form.touchField(props.path);
110
+ if (dequal(current, getProperty(form.initialValues, props.path))) form.dirtyFields.delete(props.path);
111
+ else form.dirtyField(props.path);
112
+ if (validateOn.value.onChange) validate();
113
+ }
114
+ /**
115
+ * Validates the array field
116
+ * @returns Promise that resolves to the validation result
117
+ */
118
+ async function validate() {
119
+ isValidating.value = true;
120
+ try {
121
+ return await form.validateField(props.path);
122
+ } finally {
376
123
  isValidating.value = false;
377
124
  }
378
- };
379
- const onBlur = () => {
380
- clearDebounce();
381
- form.touchField(props.path);
382
- if (validateOn.value.onBlur) validate();
383
- };
384
- const onInput = () => {
385
- updateDirty();
386
- if (!validateOn.value.onInput) return;
387
- if (form.validationMode.eager && !isValid.value) scheduleValidation();
388
- };
389
- const onChange = () => {
390
- updateDirty();
391
- if (!validateOn.value.onChange) return;
392
- if (form.validationMode.eager && !isValid.value) scheduleValidation();
393
- };
394
- const onFocus = () => {
395
- if (validateOn.value.onFocus) scheduleValidation();
396
- };
125
+ }
126
+ /**
127
+ * Appends an item to the array
128
+ * @param value The value to append
129
+ */
130
+ function append(value) {
131
+ itemKeys.value.push(generateKey());
132
+ mutate((current) => current.push(value));
133
+ }
134
+ /**
135
+ * Prepends an item to the array
136
+ * @param value The value to prepend
137
+ */
138
+ function prepend(value) {
139
+ itemKeys.value.unshift(generateKey());
140
+ mutate((current) => current.unshift(value));
141
+ }
142
+ /**
143
+ * Removes an item from the array
144
+ * @param index The index of the item to remove
145
+ */
146
+ function remove(index) {
147
+ itemKeys.value.splice(index, 1);
148
+ mutate((current) => current.splice(index, 1));
149
+ }
150
+ /**
151
+ * Inserts an item at a specific index
152
+ * @param index The index to insert at
153
+ * @param value The value to insert
154
+ */
155
+ function insert(index, value) {
156
+ itemKeys.value.splice(index, 0, generateKey());
157
+ mutate((current) => current.splice(index, 0, value));
158
+ }
159
+ /**
160
+ * Updates an item at a specific index
161
+ * @param index The index to update
162
+ * @param value The new value
163
+ */
164
+ function update(index, value) {
165
+ mutate((current) => {
166
+ current[index] = value;
167
+ });
168
+ }
169
+ /**
170
+ * Swaps two items in the array
171
+ * @param indexA The first index
172
+ * @param indexB The second index
173
+ */
174
+ function swap(indexA, indexB) {
175
+ const tempKey = itemKeys.value[indexA];
176
+ itemKeys.value[indexA] = itemKeys.value[indexB];
177
+ itemKeys.value[indexB] = tempKey;
178
+ mutate((current) => {
179
+ const temp = current[indexA];
180
+ current[indexA] = current[indexB];
181
+ current[indexB] = temp;
182
+ });
183
+ }
184
+ /**
185
+ * Moves an item from one index to another
186
+ * @param from The source index
187
+ * @param to The destination index
188
+ */
189
+ function move(from, to) {
190
+ const [key] = itemKeys.value.splice(from, 1);
191
+ itemKeys.value.splice(to, 0, key);
192
+ mutate((current) => {
193
+ const [item] = current.splice(from, 1);
194
+ current.splice(to, 0, item);
195
+ });
196
+ }
397
197
  onMounted(async () => {
398
198
  await nextTick();
399
199
  if (validateOn.value.onMount) validate();
400
200
  });
401
- onUnmounted(clearDebounce);
402
- const slotProps = computed(() => ({
403
- path: props.path,
404
- value: value.value,
405
- errors: errors.value,
406
- isValid: isValid.value,
407
- isTouched: isTouched.value,
408
- isDirty: isDirty.value,
409
- isValidating: isValidating.value,
410
- validate,
411
- events: {
412
- onBlur,
413
- onInput,
414
- onChange,
415
- onFocus
416
- }
417
- }));
201
+ watch(() => array.value.length, syncKeys);
418
202
  return (_ctx, _cache) => {
419
- return renderSlot(_ctx.$slots, "default", normalizeProps(guardReactiveProps(slotProps.value)));
203
+ return renderSlot(_ctx.$slots, "default", {
204
+ append,
205
+ errors: errors.value,
206
+ insert,
207
+ isDirty: isDirty.value,
208
+ isTouched: isTouched.value,
209
+ isValid: isValid.value,
210
+ isValidating: isValidating.value,
211
+ items: items.value,
212
+ move,
213
+ path: __props.path,
214
+ prepend,
215
+ remove,
216
+ swap,
217
+ update,
218
+ validate
219
+ });
420
220
  };
421
221
  }
422
222
  });
423
223
  //#endregion
424
- //#region src/components/not-message.vue
425
- var not_message_default = /* @__PURE__ */ defineComponent({
426
- inheritAttrs: false,
427
- __name: "not-message",
224
+ //#region src/components/not-field.vue
225
+ var not_field_default = /* @__PURE__ */ defineComponent({
226
+ __name: "not-field",
428
227
  props: {
429
228
  path: {
430
229
  type: String,
431
230
  required: true
432
231
  },
433
- as: {
434
- type: String,
435
- required: false,
436
- default: "span"
437
- },
438
- form: {
439
- type: null,
440
- required: false
441
- }
442
- },
443
- setup(__props) {
444
- const attributes = useAttrs();
445
- const props = __props;
446
- const form = useNotFormInstance(props.form);
447
- const message = computed(() => form.errorsMap.value[props.path]);
448
- const slotProps = computed(() => ({
449
- message: message.value,
450
- attributes
451
- }));
452
- return (_ctx, _cache) => {
453
- return renderSlot(_ctx.$slots, "default", normalizeProps(guardReactiveProps(slotProps.value)), () => [message.value ? (openBlock(), createBlock(resolveDynamicComponent(props.as), normalizeProps(mergeProps({ key: 0 }, unref(attributes))), {
454
- default: withCtx(() => [createTextVNode(toDisplayString(message.value), 1)]),
455
- _: 1
456
- }, 16)) : createCommentVNode("v-if", true)]);
457
- };
458
- }
459
- });
460
- //#endregion
461
- //#region src/components/not-array-field.vue
462
- var not_array_field_default = /* @__PURE__ */ defineComponent({
463
- inheritAttrs: false,
464
- __name: "not-array-field",
465
- props: {
466
- path: {
467
- type: String,
468
- required: true
469
- },
470
- itemSchema: {
471
- type: null,
472
- required: false
473
- },
474
232
  form: {
475
233
  type: null,
476
234
  required: false
@@ -478,147 +236,444 @@ var not_array_field_default = /* @__PURE__ */ defineComponent({
478
236
  validateOn: {
479
237
  type: Object,
480
238
  required: false
239
+ },
240
+ debounce: {
241
+ type: Number,
242
+ required: false
481
243
  }
482
244
  },
483
245
  setup(__props) {
484
246
  const props = __props;
485
247
  const form = useNotFormInstance(props.form);
248
+ const isValidating = ref(false);
249
+ /** Timer handle for the current pending debounced validation. */
250
+ let debounceTimer;
251
+ /** Merges per-field overrides with the form-wide validation config */
486
252
  const validateOn = computed(() => ({
487
- onMount: props.validateOn?.onMount ?? form.validateOn.onMount,
488
- onChange: props.validateOn?.onChange ?? form.validateOn.onChange
253
+ ...form.validateOn,
254
+ ...props.validateOn
489
255
  }));
490
- const isValidating = ref(false);
491
- /**
492
- * Stable keys per item — survive reorders, removals, and inserts.
493
- * Seeded from the length of the initial array so existing items have keys from the start.
494
- */
495
- const itemKeys = ref((() => {
496
- const initial = getProperty(form.values, props.path);
497
- const length = Array.isArray(initial) ? initial.length : 0;
498
- return Array.from({ length }, (_, index) => `${props.path}-${index}-${Date.now()}`);
499
- })());
500
- let keyCounter = itemKeys.value.length;
501
- const generateKey = () => `${props.path}-${keyCounter++}-${Date.now()}`;
502
- const array = computed(() => {
503
- const value = getProperty(form.values, props.path);
504
- return Array.isArray(value) ? value : [];
505
- });
506
- const items = computed(() => array.value.map((_, index) => ({
507
- key: itemKeys.value[index] ?? `${props.path}-fallback-${index}`,
508
- index,
509
- path: `${props.path}.${index}`
510
- })));
256
+ const value = computed(() => getProperty(form.values, props.path));
511
257
  const errors = computed(() => form.getFieldErrors(props.path));
512
258
  const isValid = computed(() => errors.value.length === 0);
259
+ const isTouched = computed(() => form.touchedFields.has(props.path));
260
+ const isDirty = computed(() => form.dirtyFields.has(props.path));
261
+ /** Cancels pending debounced validation on blur/unmount to prevent rogue execution. */
262
+ function clearDebounce() {
263
+ if (debounceTimer !== void 0) {
264
+ clearTimeout(debounceTimer);
265
+ debounceTimer = void 0;
266
+ }
267
+ }
268
+ /** Compares current value against baseline to sync dirty state tracking. */
269
+ function updateDirty() {
270
+ if (dequal(value.value, getProperty(form.initialValues, props.path))) form.dirtyFields.delete(props.path);
271
+ else form.dirtyField(props.path);
272
+ }
273
+ /** Replaces pending validations with a new timer, or runs synchronously if no debounce. */
274
+ function scheduleValidation() {
275
+ if (!props.debounce) {
276
+ validate();
277
+ return;
278
+ }
279
+ clearDebounce();
280
+ debounceTimer = setTimeout(validate, props.debounce);
281
+ }
513
282
  /**
514
- * Derived from the form's touchedFields set.
515
- * True if the array path itself or any of its item paths have been touched.
516
- */
517
- const isTouched = computed(() => form.touchedFields.has(props.path) || [...form.touchedFields].some((path) => path.startsWith(`${props.path}.`)));
518
- /**
519
- * Derived from the form's dirtyFields set.
520
- * True if the array path itself or any of its item paths are dirty.
283
+ * Validates the field and returns the validation result.
284
+ * @returns A promise that resolves to the validation result.
521
285
  */
522
- const isDirty = computed(() => form.dirtyFields.has(props.path) || [...form.dirtyFields].some((path) => path.startsWith(`${props.path}.`)));
523
- const validate = async () => {
286
+ async function validate() {
524
287
  isValidating.value = true;
525
288
  try {
526
289
  return await form.validateField(props.path);
527
290
  } finally {
528
291
  isValidating.value = false;
529
292
  }
530
- };
531
- /** Re-aligns itemKeys with the current array length. */
532
- const syncKeys = () => {
533
- const arrayLength = array.value.length;
534
- if (itemKeys.value.length > arrayLength) itemKeys.value.length = arrayLength;
535
- else while (itemKeys.value.length < arrayLength) itemKeys.value.push(generateKey());
536
- };
537
- /**
538
- * Applies an update to a copy of the current array, writes it back to the form values,
539
- * and marks the array path as touched. Dirty state is derived from the form's Sets
540
- * automatically via the computed above so no explicit dirty call is needed here.
541
- */
542
- const mutate = (updater) => {
543
- const current = [...array.value];
544
- updater(current);
545
- setProperty(form.values, props.path, current);
293
+ }
294
+ /** Handles the blur event for the field. */
295
+ function onBlur() {
296
+ clearDebounce();
546
297
  form.touchField(props.path);
547
- if (dequal(current, getProperty(form.initialValues, props.path))) form.dirtyFields.delete(props.path);
548
- else form.dirtyField(props.path);
549
- if (validateOn.value.onChange) validate();
550
- };
551
- const append = (value) => {
552
- itemKeys.value.push(generateKey());
553
- mutate((current) => current.push(value));
554
- };
555
- const prepend = (value) => {
556
- itemKeys.value.unshift(generateKey());
557
- mutate((current) => current.unshift(value));
558
- };
559
- const remove = (index) => {
560
- mutate((current) => current.splice(index, 1));
561
- itemKeys.value.splice(index, 1);
562
- };
563
- const insert = (index, value) => {
564
- itemKeys.value.splice(index, 0, generateKey());
565
- mutate((current) => current.splice(index, 0, value));
566
- };
567
- const update = (index, value) => {
568
- mutate((current) => {
569
- current[index] = value;
570
- });
571
- };
572
- const swap = (indexA, indexB) => {
573
- mutate((current) => {
574
- [current[indexA], current[indexB]] = [current[indexB], current[indexA]];
575
- });
576
- [itemKeys.value[indexA], itemKeys.value[indexB]] = [itemKeys.value[indexB], itemKeys.value[indexA]];
577
- };
578
- const move = (from, to) => {
579
- mutate((current) => {
580
- const [item] = current.splice(from, 1);
581
- current.splice(to, 0, item);
582
- });
583
- const [key] = itemKeys.value.splice(from, 1);
584
- itemKeys.value.splice(to, 0, key);
585
- };
298
+ if (validateOn.value.onBlur) validate();
299
+ }
300
+ /** Handles the input event for the field. */
301
+ function onInput() {
302
+ updateDirty();
303
+ if (!validateOn.value.onInput) return;
304
+ if (!form.validationMode.eager || !isValid.value) scheduleValidation();
305
+ }
306
+ /** Handles the change event for the field. */
307
+ function onChange() {
308
+ updateDirty();
309
+ if (!validateOn.value.onChange) return;
310
+ if (!form.validationMode.eager || !isValid.value) scheduleValidation();
311
+ }
312
+ /** Handles the focus event for the field. */
313
+ function onFocus() {
314
+ if (validateOn.value.onFocus) scheduleValidation();
315
+ }
586
316
  onMounted(async () => {
587
317
  await nextTick();
588
318
  if (validateOn.value.onMount) validate();
589
319
  });
590
- /**
591
- * Syncs `itemKeys` when the array length changes outside of this component's
592
- * own mutation methods — most commonly after `form.reset()`.
593
- *
594
- * Watching `length` rather than the full array avoids unnecessary syncs on
595
- * `update()` and `swap()`, which mutate values but never add or remove items.
596
- * When our own mutations run, they manage keys explicitly before writing to
597
- * the array, so by the time this watcher fires the lengths already match and
598
- * `syncKeys` is a cheap no-op.
599
- */
600
- watch(() => array.value.length, syncKeys);
601
- const slotProps = computed(() => ({
602
- path: props.path,
603
- items: items.value,
604
- errors: errors.value,
605
- isValid: isValid.value,
606
- isTouched: isTouched.value,
607
- isDirty: isDirty.value,
608
- isValidating: isValidating.value,
609
- validate,
610
- append,
611
- prepend,
612
- remove,
613
- insert,
614
- update,
615
- swap,
616
- move
617
- }));
320
+ onUnmounted(() => {
321
+ clearDebounce();
322
+ });
323
+ return (_ctx, _cache) => {
324
+ return renderSlot(_ctx.$slots, "default", {
325
+ errors: errors.value,
326
+ isValid: isValid.value,
327
+ isTouched: isTouched.value,
328
+ isDirty: isDirty.value,
329
+ isValidating: isValidating.value,
330
+ validate,
331
+ events: {
332
+ onBlur,
333
+ onChange,
334
+ onFocus,
335
+ onInput
336
+ },
337
+ path: __props.path,
338
+ value: value.value
339
+ });
340
+ };
341
+ }
342
+ });
343
+ //#endregion
344
+ //#region src/components/not-form.vue
345
+ var not_form_default = /* @__PURE__ */ defineComponent({
346
+ __name: "not-form",
347
+ props: { form: {
348
+ type: null,
349
+ required: true
350
+ } },
351
+ setup(__props) {
352
+ provideNotFormInstance(__props.form);
353
+ return (_ctx, _cache) => {
354
+ return openBlock(), createElementBlock("form", null, [renderSlot(_ctx.$slots, "default")]);
355
+ };
356
+ }
357
+ });
358
+ //#endregion
359
+ //#region src/components/not-message.vue
360
+ var not_message_default = /* @__PURE__ */ defineComponent({
361
+ inheritAttrs: false,
362
+ __name: "not-message",
363
+ props: {
364
+ as: {
365
+ type: null,
366
+ required: false,
367
+ default: "span"
368
+ },
369
+ path: {
370
+ type: String,
371
+ required: true
372
+ },
373
+ form: {
374
+ type: null,
375
+ required: false
376
+ }
377
+ },
378
+ setup(__props) {
379
+ const props = __props;
380
+ const form = useNotFormInstance(props.form);
381
+ const message = computed(() => form.errorsMap.value[props.path]);
618
382
  return (_ctx, _cache) => {
619
- return renderSlot(_ctx.$slots, "default", normalizeProps(guardReactiveProps(slotProps.value)));
383
+ return message.value ? (openBlock(), createBlock(resolveDynamicComponent(__props.as), normalizeProps(mergeProps({ key: 0 }, _ctx.$attrs)), {
384
+ default: withCtx(() => [renderSlot(_ctx.$slots, "default", { message: message.value }, () => [createTextVNode(toDisplayString(message.value), 1)])]),
385
+ _: 3
386
+ }, 16)) : createCommentVNode("v-if", true);
620
387
  };
621
388
  }
622
389
  });
623
390
  //#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
+ //#region src/composables/use-not-form.ts
419
+ /**
420
+ * Creates a reactive NotFormInstance for managing form state and validation.
421
+ * @template TSchema The standard schema type.
422
+ * @param config Configuration object.
423
+ * @returns A reactive NotFormInstance.
424
+ */
425
+ function useNotForm(config) {
426
+ let initialValues = klona(config.initialValues ?? {});
427
+ let initialErrors = klona(config.initialErrors ?? []);
428
+ const validateOn = {
429
+ onBlur: config.validateOn?.onBlur ?? true,
430
+ onChange: config.validateOn?.onChange ?? true,
431
+ onFocus: config.validateOn?.onFocus ?? false,
432
+ onInput: config.validateOn?.onInput ?? true,
433
+ onMount: config.validateOn?.onMount ?? false
434
+ };
435
+ const validationMode = {
436
+ eager: config.validationMode?.eager ?? true,
437
+ lazy: config.validationMode?.lazy ?? false
438
+ };
439
+ const values = reactive(klona(initialValues));
440
+ const errors = reactive([...initialErrors]);
441
+ const touchedFields = reactive(/* @__PURE__ */ new Set());
442
+ const dirtyFields = reactive(/* @__PURE__ */ new Set());
443
+ const isSubmitting = ref(false);
444
+ const isValidating = ref(false);
445
+ /**
446
+ * Tracks concurrent validations to keep `isValidating` true until all finish.
447
+ * @internal
448
+ */
449
+ let validatingCount = 0;
450
+ const isValid = computed(() => errors.length === 0);
451
+ const isDirty = computed(() => dirtyFields.size > 0);
452
+ const isTouched = computed(() => touchedFields.size > 0);
453
+ const errorsMap = computed(() => {
454
+ const result = {};
455
+ for (const issue of errors) if (issue.path) {
456
+ const path = issue.path.map((element) => normalizeSegment(element)).join(".");
457
+ if (path && !result[path]) result[path] = issue.message;
458
+ }
459
+ return result;
460
+ });
461
+ /**
462
+ * Increments validation counter and activates `isValidating`.
463
+ * @internal
464
+ */
465
+ function beginValidating() {
466
+ validatingCount++;
467
+ isValidating.value = true;
468
+ }
469
+ /**
470
+ * Decrements validation counter and deactivates `isValidating` when zero.
471
+ * @internal
472
+ */
473
+ function endValidating() {
474
+ validatingCount--;
475
+ if (validatingCount === 0) isValidating.value = false;
476
+ }
477
+ /**
478
+ * Resolves and executes the standard schema validation.
479
+ * @internal
480
+ * @returns The validation result containing either `value` or `issues`.
481
+ */
482
+ function runSchema() {
483
+ return toValue(config.schema)["~standard"].validate(values);
484
+ }
485
+ /**
486
+ * Iterates through deeply nested keys to mark all as touched.
487
+ * @internal
488
+ */
489
+ function touchAllFields() {
490
+ for (const path of deepKeys(values)) touchedFields.add(path);
491
+ }
492
+ /**
493
+ * Removes a specific field path from the dirty tracking set.
494
+ * @internal
495
+ * @param path The field path to mark as clean.
496
+ */
497
+ function unDirtyField(path) {
498
+ dirtyFields.delete(path);
499
+ }
500
+ /**
501
+ * Iterates through deeply nested keys to mark all as dirty.
502
+ * @internal
503
+ */
504
+ function dirtyAllFields() {
505
+ for (const path of deepKeys(values)) dirtyFields.add(path);
506
+ }
507
+ /**
508
+ * Marks a specific field path as touched.
509
+ * @param path The field path to mark as touched.
510
+ */
511
+ function touchField(path) {
512
+ touchedFields.add(path);
513
+ }
514
+ /**
515
+ * Marks a specific field path as dirty.
516
+ * @param path The field path to mark as dirty.
517
+ */
518
+ function dirtyField(path) {
519
+ dirtyFields.add(path);
520
+ }
521
+ /**
522
+ * Sets a value at the specified path and updates dirty tracking based on comparison with initial values.
523
+ * @param path The field path to set the value at.
524
+ * @param value The value to set.
525
+ */
526
+ function setValue(path, value) {
527
+ setProperty(values, path, value);
528
+ if (dequal(value, getProperty(initialValues, path))) unDirtyField(path);
529
+ else dirtyField(path);
530
+ }
531
+ /**
532
+ * Sets or updates an error issue in the errors array, replacing any existing issue for the same path.
533
+ * @param newIssue The issue to set.
534
+ */
535
+ function setError(newIssue) {
536
+ const newPath = newIssue.path?.map((element) => normalizeSegment(element)).join(".");
537
+ const existingIndex = errors.findIndex((error) => {
538
+ return error.path?.map((element) => normalizeSegment(element)).join(".") === newPath;
539
+ });
540
+ if (existingIndex === -1) errors.push(newIssue);
541
+ else errors[existingIndex] = newIssue;
542
+ }
543
+ /**
544
+ * Sets multiple error issues in the errors array, replacing any existing issues.
545
+ * @param newIssues The array of issues to set.
546
+ */
547
+ function setErrors(newIssues) {
548
+ errors.splice(0, errors.length, ...newIssues);
549
+ }
550
+ /** Clears all error issues from the errors array. */
551
+ function clearErrors() {
552
+ errors.splice(0);
553
+ }
554
+ /**
555
+ * Retrieves all error issues associated with a specific field path.
556
+ * @param path The field path to retrieve errors for.
557
+ * @returns An array of error issues associated with the field path.
558
+ */
559
+ function getFieldErrors(path) {
560
+ const pathSegments = parsePath(path);
561
+ return errors.filter((error) => isIssuePathEqual(error.path, pathSegments));
562
+ }
563
+ /**
564
+ * Validates the entire form against the schema, updating errors and returning the result.
565
+ * @returns An object containing either `value` or `issues` based on validation outcome.
566
+ */
567
+ async function validate() {
568
+ beginValidating();
569
+ try {
570
+ const result = await runSchema();
571
+ if (result?.issues) {
572
+ setErrors([...result.issues]);
573
+ return { issues: result.issues };
574
+ }
575
+ clearErrors();
576
+ return { value: result.value };
577
+ } finally {
578
+ endValidating();
579
+ }
580
+ }
581
+ /**
582
+ * Validates a specific field against the schema, updating errors and returning the result.
583
+ * @param path The field path to validate.
584
+ * @returns An object containing either `value` or `issues` based on validation outcome.
585
+ */
586
+ async function validateField(path) {
587
+ beginValidating();
588
+ try {
589
+ const result = await runSchema();
590
+ const pathSegments = parsePath(path);
591
+ const staleIndices = [];
592
+ 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);
594
+ if (result?.issues) {
595
+ const fieldIssues = result.issues.filter((issue) => isIssuePathEqual(issue.path, pathSegments));
596
+ if (fieldIssues.length > 0) {
597
+ errors.push(...fieldIssues);
598
+ return { issues: fieldIssues };
599
+ }
600
+ return { value: getProperty(values, path) };
601
+ }
602
+ return { value: getProperty(result.value, path) };
603
+ } finally {
604
+ endValidating();
605
+ }
606
+ }
607
+ /**
608
+ * Submits the form, validating it and executing the submit handler if provided.
609
+ * @param event The submit event.
610
+ */
611
+ async function submit(event) {
612
+ isSubmitting.value = true;
613
+ try {
614
+ touchAllFields();
615
+ dirtyAllFields();
616
+ const result = await validate();
617
+ if (result?.issues) {
618
+ event.preventDefault();
619
+ return;
620
+ }
621
+ if (config.onSubmit) {
622
+ event.preventDefault();
623
+ await config.onSubmit(result.value);
624
+ }
625
+ } catch {
626
+ event.preventDefault();
627
+ } finally {
628
+ isSubmitting.value = false;
629
+ }
630
+ }
631
+ /**
632
+ * Resets the form to its initial state.
633
+ * @param newValues The new values to reset the form with.
634
+ * @param newErrors The new errors to reset the form with.
635
+ */
636
+ function reset(newValues, newErrors) {
637
+ if (newValues) initialValues = klona(newValues);
638
+ if (newErrors) initialErrors = klona(newErrors);
639
+ const freshValues = klona(initialValues);
640
+ for (const key of Object.keys(values)) if (!hasProperty(freshValues, key)) deleteProperty(values, key);
641
+ for (const key of Object.keys(freshValues)) setProperty(values, key, getProperty(freshValues, key));
642
+ errors.splice(0, errors.length, ...klona(initialErrors));
643
+ touchedFields.clear();
644
+ dirtyFields.clear();
645
+ }
646
+ return markRaw({
647
+ get initialErrors() {
648
+ return initialErrors;
649
+ },
650
+ get initialValues() {
651
+ return initialValues;
652
+ },
653
+ validateOn,
654
+ validationMode,
655
+ setValue,
656
+ values,
657
+ isTouched,
658
+ touchedFields,
659
+ touchField,
660
+ dirtyField,
661
+ dirtyFields,
662
+ isDirty,
663
+ clearErrors,
664
+ errors,
665
+ errorsMap,
666
+ getFieldErrors,
667
+ setError,
668
+ setErrors,
669
+ isValid,
670
+ isValidating,
671
+ validate,
672
+ validateField,
673
+ isSubmitting,
674
+ submit,
675
+ reset
676
+ });
677
+ }
678
+ //#endregion
624
679
  export { not_array_field_default as NotArrayField, not_field_default as NotField, not_form_default as NotForm, not_message_default as NotMessage, useNotForm };