@vuehookform/core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,16 @@
1
1
  import { FormContext } from './formContext';
2
- import { RegisterOptions, RegisterReturn, UnregisterOptions, Path } from '../types';
2
+ import { RegisterOptions, RegisterReturn, UnregisterOptions, SetValueOptions, Path } from '../types';
3
+ /**
4
+ * Helpers object for onChange callbacks.
5
+ * Populated after useForm's setValue is defined (mutable reference pattern).
6
+ */
7
+ export interface OnChangeHelpers {
8
+ setValue: (name: string, value: unknown, options?: SetValueOptions) => void;
9
+ }
3
10
  /**
4
11
  * Create field registration functions
5
12
  */
6
- export declare function createFieldRegistration<FormValues>(ctx: FormContext<FormValues>, validate: (fieldPath?: string) => Promise<boolean>): {
13
+ export declare function createFieldRegistration<FormValues>(ctx: FormContext<FormValues>, validate: (fieldPath?: string) => Promise<boolean>, onChangeHelpers?: OnChangeHelpers): {
7
14
  register: <TPath extends Path<FormValues>>(name: TPath, registerOptions?: RegisterOptions) => RegisterReturn;
8
15
  unregister: <TPath extends Path<FormValues>>(name: TPath, options?: UnregisterOptions) => void;
9
16
  };
package/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@ export { provideForm, useFormContext, FormContextKey } from './context';
19
19
  export { useWatch, type UseWatchOptions } from './useWatch';
20
20
  export { useController, type UseControllerOptions, type UseControllerReturn, type ControllerFieldProps, type LooseControllerOptions, } from './useController';
21
21
  export { useFormState, type UseFormStateOptions, type FormStateKey } from './useFormState';
22
+ export { useFieldError, type UseFieldErrorOptions, type LooseFieldErrorOptions, } from './useFieldError';
22
23
  export { isFieldError } from './types';
23
24
  export type { UseFormOptions, UseFormReturn, UseFormReturn as Control, LooseControl, RegisterOptions, RegisterReturn, FormState, FieldState, FieldErrors, FieldError, FieldErrorValue, ErrorOption, SetErrorsOptions, FieldArray, FieldArrayItem, FieldArrayOptions, FieldArrayRules, FieldArrayFocusOptions, InferSchema, FormValues, FormPath, Path, PathValue, ArrayElement, ArrayPath, FieldPath, ValidationMode, SetFocusOptions, ResetOptions, ResetFieldOptions, AsyncDefaultValues, TriggerOptions, SetValueOptions, UnregisterOptions, CriteriaMode, } from './types';
24
25
  export { get, set, unset, clearPathCache } from './utils/paths';
package/dist/types.d.ts CHANGED
@@ -186,6 +186,10 @@ export interface FormState<T> {
186
186
  isSubmitSuccessful: boolean;
187
187
  /** Whether the form is disabled */
188
188
  disabled: boolean;
189
+ /** Whether form values match default values (opposite of isDirty) */
190
+ isPristine: boolean;
191
+ /** Whether the form can be submitted (isValid && !isSubmitting && !isLoading && !disabled) */
192
+ canSubmit: boolean;
189
193
  }
190
194
  /**
191
195
  * State of an individual field
@@ -328,6 +332,26 @@ export interface RegisterOptions<TValue = unknown> {
328
332
  shouldUnregister?: boolean;
329
333
  /** Dependent fields to re-validate when this field changes */
330
334
  deps?: string[];
335
+ /**
336
+ * Called after the field value changes via user input.
337
+ * Use for side effects like resetting dependent fields.
338
+ * Does NOT fire on programmatic setValue() calls.
339
+ *
340
+ * @param value - The new field value (typed based on field path)
341
+ * @param helpers - Helper functions for triggering side effects
342
+ *
343
+ * @example Reset dependent field when parent changes
344
+ * ```ts
345
+ * register('country', {
346
+ * onChange: (country, { setValue }) => {
347
+ * setValue('province', '') // Reset province when country changes
348
+ * },
349
+ * })
350
+ * ```
351
+ */
352
+ onChange?: (value: TValue, helpers: {
353
+ setValue: (name: string, value: unknown, options?: SetValueOptions) => void;
354
+ }) => void;
331
355
  }
332
356
  /**
333
357
  * Return value from register() for binding to inputs.
@@ -0,0 +1,54 @@
1
+ import { ComputedRef } from 'vue';
2
+ import { ZodType } from 'zod';
3
+ import { UseFormReturn, Path, InferSchema, LooseControl } from './types';
4
+ /**
5
+ * Options for useFieldError composable
6
+ */
7
+ export interface UseFieldErrorOptions<TSchema extends ZodType, TPath extends Path<InferSchema<TSchema>>> {
8
+ /** Field path to get the error for */
9
+ name: TPath;
10
+ /** Form control from useForm (uses context if not provided) */
11
+ control?: UseFormReturn<TSchema>;
12
+ }
13
+ /**
14
+ * Loose options for useFieldError when schema type is unknown.
15
+ */
16
+ export interface LooseFieldErrorOptions {
17
+ /** Field path as a string */
18
+ name: string;
19
+ /** Form control from useForm (uses context if not provided) */
20
+ control?: LooseControl;
21
+ }
22
+ /**
23
+ * Get a reactive error message for a single field.
24
+ *
25
+ * Returns a ComputedRef that automatically updates when the field's
26
+ * error state changes. Normalizes both string errors and structured
27
+ * FieldError objects to a plain string message.
28
+ *
29
+ * For field array paths with per-item errors (not a single array-level error),
30
+ * returns `undefined`. Use `formState.value.errors` directly for item-level errors.
31
+ *
32
+ * @example Basic usage with context
33
+ * ```vue
34
+ * <script setup>
35
+ * const emailError = useFieldError({ name: 'email' })
36
+ * </script>
37
+ * <template>
38
+ * <span v-if="emailError" class="error">{{ emailError }}</span>
39
+ * </template>
40
+ * ```
41
+ *
42
+ * @example With explicit control
43
+ * ```ts
44
+ * const form = useForm({ schema })
45
+ * const emailError = useFieldError({ name: 'email', control: form })
46
+ * ```
47
+ *
48
+ * @example Nested path
49
+ * ```ts
50
+ * const cityError = useFieldError({ name: 'user.address.city' })
51
+ * ```
52
+ */
53
+ export declare function useFieldError(options: LooseFieldErrorOptions): ComputedRef<string | undefined>;
54
+ export declare function useFieldError<TSchema extends ZodType, TPath extends Path<InferSchema<TSchema>>>(options: UseFieldErrorOptions<TSchema, TPath>): ComputedRef<string | undefined>;
@@ -23,17 +23,16 @@ export interface UseFormStateOptions<TSchema extends ZodType> {
23
23
  * @example
24
24
  * ```ts
25
25
  * // Subscribe to all form state
26
- * const formState = useFormState({})
26
+ * const state = useFormState({})
27
+ * // Access: state.value.errors, state.value.isDirty, etc.
27
28
  *
28
29
  * // Subscribe to specific properties
29
- * const { isSubmitting, errors } = useFormState({ name: ['isSubmitting', 'errors'] })
30
- *
31
- * // Subscribe to single property
32
- * const isDirty = useFormState({ name: 'isDirty' })
30
+ * const state = useFormState({ name: ['isSubmitting', 'errors'] })
31
+ * // Access: state.value.isSubmitting, state.value.errors
33
32
  *
34
33
  * // With explicit control
35
34
  * const { control } = useForm({ schema })
36
- * const formState = useFormState({ control })
35
+ * const state = useFormState({ control })
37
36
  * ```
38
37
  */
39
38
  export declare function useFormState<TSchema extends ZodType>(options?: UseFormStateOptions<TSchema>): ComputedRef<Partial<FormState<InferSchema<TSchema>>>>;
@@ -10,7 +10,7 @@ export interface UseWatchOptions<TSchema extends ZodType, TPath extends Path<Inf
10
10
  /** Field path or array of paths to watch (watches all if not provided) */
11
11
  name?: TPath | TPath[];
12
12
  /** Default value when field is undefined */
13
- defaultValue?: unknown;
13
+ defaultValue?: PathValue<InferSchema<TSchema>, TPath>;
14
14
  }
15
15
  /**
16
16
  * Watch form field values reactively without the full form instance
@@ -541,14 +541,15 @@ function clearFieldTouched(touchedFields, fieldName) {
541
541
  }
542
542
  function clearFieldErrors(errors, fieldName) {
543
543
  const currentErrors = errors.value;
544
- const keys = Object.keys(currentErrors);
545
- if (keys.length === 0) return;
544
+ if (Object.keys(currentErrors).length === 0) return;
545
+ const nestedError = get(currentErrors, fieldName);
546
546
  const prefix = `${fieldName}.`;
547
- const keysToDelete = [];
548
- for (const key of keys) if (key === fieldName || key.startsWith(prefix)) keysToDelete.push(key);
549
- if (keysToDelete.length === 0) return;
547
+ const flatKeysToDelete = [];
548
+ for (const key of Object.keys(currentErrors)) if (key === fieldName || key.startsWith(prefix)) flatKeysToDelete.push(key);
549
+ if (nestedError === void 0 && flatKeysToDelete.length === 0) return;
550
550
  const newErrors = { ...currentErrors };
551
- for (const key of keysToDelete) delete newErrors[key];
551
+ for (const key of flatKeysToDelete) delete newErrors[key];
552
+ if (nestedError !== void 0) unset(newErrors, fieldName);
552
553
  errors.value = newErrors;
553
554
  }
554
555
  function updateFieldDirtyState(dirtyFields, defaultValues, defaultValueHashes, fieldName, currentValue) {
@@ -592,7 +593,6 @@ function createFieldError(errors, criteriaMode = "firstError") {
592
593
  const firstError = errors[0];
593
594
  if (!firstError) return "";
594
595
  if (criteriaMode === "firstError") return firstError.message;
595
- if (errors.length === 1) return firstError.message;
596
596
  const types = {};
597
597
  for (const err of errors) {
598
598
  const existing = types[err.type];
@@ -818,7 +818,7 @@ function shouldValidateOnBlur(mode, hasSubmitted, reValidateMode) {
818
818
  return mode === "onBlur" || mode === "onTouched" || hasSubmitted && (reValidateMode === "onBlur" || reValidateMode === "onTouched");
819
819
  }
820
820
  var validationRequestCounter = 0;
821
- function createFieldRegistration(ctx, validate) {
821
+ function createFieldRegistration(ctx, validate, onChangeHelpers) {
822
822
  function register(name, registerOptions) {
823
823
  if (__DEV__) {
824
824
  const syntaxError = validatePathSyntax(name);
@@ -845,13 +845,13 @@ function createFieldRegistration(ctx, validate) {
845
845
  const error = await fieldOpts.validate(value);
846
846
  if (requestId !== ctx.validationRequestIds.get(fieldName)) return;
847
847
  if (ctx.resetGeneration.value !== resetGenAtStart) return;
848
- if (error) ctx.errors.value = {
849
- ...ctx.errors.value,
850
- [fieldName]: error
851
- };
852
- else {
848
+ if (error) {
849
+ const newErrors = { ...ctx.errors.value };
850
+ set(newErrors, fieldName, error);
851
+ ctx.errors.value = newErrors;
852
+ } else {
853
853
  const newErrors = { ...ctx.errors.value };
854
- delete newErrors[fieldName];
854
+ unset(newErrors, fieldName);
855
855
  ctx.errors.value = newErrors;
856
856
  }
857
857
  };
@@ -864,6 +864,11 @@ function createFieldRegistration(ctx, validate) {
864
864
  set(ctx.formData, name, value);
865
865
  updateFieldDirtyState(ctx.dirtyFields, ctx.defaultValues, ctx.defaultValueHashes, name, value);
866
866
  const fieldOpts = ctx.fieldOptions.get(name);
867
+ if (fieldOpts?.onChange && onChangeHelpers?.setValue) try {
868
+ fieldOpts.onChange(value, { setValue: onChangeHelpers.setValue });
869
+ } catch (err) {
870
+ if (__DEV__) console.error(`[vue-hook-form] Error in onChange callback for field '${name}':`, err);
871
+ }
867
872
  if (shouldValidateOnChange(ctx.options.mode ?? "onSubmit", ctx.touchedFields.value[name] === true, ctx.submitCount.value > 0, ctx.options.reValidateMode)) {
868
873
  const validationDebounceMs = ctx.options.validationDebounce || 0;
869
874
  if (validationDebounceMs > 0) {
@@ -1423,7 +1428,8 @@ function useForm(options) {
1423
1428
  ctx.cleanup();
1424
1429
  });
1425
1430
  const { validate, clearAllPendingErrors } = createValidation(ctx);
1426
- const { register, unregister } = createFieldRegistration(ctx, validate);
1431
+ const onChangeHelpers = { setValue: null };
1432
+ const { register, unregister } = createFieldRegistration(ctx, validate, onChangeHelpers);
1427
1433
  function setFocus(name, focusOptions) {
1428
1434
  if (__DEV__) {
1429
1435
  const syntaxError = validatePathSyntax(name);
@@ -1514,6 +1520,12 @@ function useForm(options) {
1514
1520
  },
1515
1521
  get disabled() {
1516
1522
  return ctx.isDisabled.value;
1523
+ },
1524
+ get isPristine() {
1525
+ return !isDirtyComputed.value;
1526
+ },
1527
+ get canSubmit() {
1528
+ return isValidComputed.value && !ctx.isSubmitting.value && !ctx.isLoading.value && !ctx.isDisabled.value;
1517
1529
  }
1518
1530
  });
1519
1531
  const formState = (0, vue.computed)(() => formStateInternal);
@@ -1529,7 +1541,7 @@ function useForm(options) {
1529
1541
  try {
1530
1542
  syncWithDebounce();
1531
1543
  if (await validate()) {
1532
- await onValid(ctx.formData);
1544
+ await onValid(deepClone(ctx.formData));
1533
1545
  ctx.isSubmitSuccessful.value = true;
1534
1546
  } else {
1535
1547
  onInvalid?.(formState.value.errors);
@@ -1563,6 +1575,7 @@ function useForm(options) {
1563
1575
  }
1564
1576
  if (setValueOptions?.shouldValidate) validate(name);
1565
1577
  }
1578
+ onChangeHelpers.setValue = setValue;
1566
1579
  function reset(values, resetOptions) {
1567
1580
  const opts = resetOptions || {};
1568
1581
  ctx.validationCache.clear();
@@ -1611,7 +1624,6 @@ function useForm(options) {
1611
1624
  }
1612
1625
  }
1613
1626
  const opts = resetFieldOptions || {};
1614
- ctx.resetGeneration.value++;
1615
1627
  ctx.validationCache.delete(`${name}:partial`);
1616
1628
  ctx.validationCache.delete(`${name}:full`);
1617
1629
  const errorTimer = ctx.errorDelayTimers.get(name);
@@ -1620,6 +1632,11 @@ function useForm(options) {
1620
1632
  ctx.errorDelayTimers.delete(name);
1621
1633
  }
1622
1634
  ctx.pendingErrors.delete(name);
1635
+ const schemaTimer = ctx.schemaValidationTimers.get(name);
1636
+ if (schemaTimer) {
1637
+ clearTimeout(schemaTimer);
1638
+ ctx.schemaValidationTimers.delete(name);
1639
+ }
1623
1640
  let defaultValue = opts.defaultValue;
1624
1641
  if (defaultValue === void 0) defaultValue = get(ctx.defaultValues, name);
1625
1642
  else {
@@ -1732,13 +1749,13 @@ function useForm(options) {
1732
1749
  }
1733
1750
  }
1734
1751
  syncWithDebounce();
1735
- if (nameOrNames === void 0) return { ...ctx.formData };
1752
+ if (nameOrNames === void 0) return deepClone(ctx.formData);
1736
1753
  if (Array.isArray(nameOrNames)) {
1737
1754
  const result = {};
1738
- for (const fieldName of nameOrNames) result[fieldName] = get(ctx.formData, fieldName);
1755
+ for (const fieldName of nameOrNames) result[fieldName] = deepClone(get(ctx.formData, fieldName));
1739
1756
  return result;
1740
1757
  }
1741
- return get(ctx.formData, nameOrNames);
1758
+ return deepClone(get(ctx.formData, nameOrNames));
1742
1759
  }
1743
1760
  function getFieldState(name) {
1744
1761
  if (__DEV__) {
@@ -1772,11 +1789,7 @@ function useForm(options) {
1772
1789
  }
1773
1790
  if (options$1?.markAsSubmitted) ctx.submitCount.value++;
1774
1791
  if (name === void 0) return await validate();
1775
- if (Array.isArray(name)) {
1776
- let allValid = true;
1777
- for (const fieldName of name) if (!await validate(fieldName)) allValid = false;
1778
- return allValid;
1779
- }
1792
+ if (Array.isArray(name)) return (await Promise.all(name.map((fieldName) => validate(fieldName)))).every((isValid) => isValid);
1780
1793
  return await validate(name);
1781
1794
  }
1782
1795
  const setFocusWrapper = (name) => setFocus(name);
@@ -1828,25 +1841,28 @@ function useFormContext() {
1828
1841
  function useWatch(options = {}) {
1829
1842
  const { control, name, defaultValue } = options;
1830
1843
  const form = control ?? useFormContext();
1831
- return (0, vue.computed)(() => {
1832
- if (name === void 0) return form.getValues();
1833
- if (Array.isArray(name)) {
1834
- const result = {};
1835
- for (const fieldName of name) result[fieldName] = get(form.getValues(), fieldName) ?? defaultValue;
1844
+ if (name === void 0) return form.watch();
1845
+ if (Array.isArray(name)) {
1846
+ const watched$1 = form.watch(name);
1847
+ if (defaultValue === void 0) return watched$1;
1848
+ return (0, vue.computed)(() => {
1849
+ const result = { ...watched$1.value };
1850
+ for (const fieldName of name) if (result[fieldName] === void 0) result[fieldName] = defaultValue;
1836
1851
  return result;
1837
- }
1838
- return get(form.getValues(), name) ?? defaultValue;
1839
- });
1852
+ });
1853
+ }
1854
+ const watched = form.watch(name);
1855
+ if (defaultValue === void 0) return watched;
1856
+ return (0, vue.computed)(() => watched.value ?? defaultValue);
1840
1857
  }
1841
1858
  function useController(options) {
1842
1859
  const { name, control, defaultValue } = options;
1843
1860
  const form = control ?? useFormContext();
1844
1861
  const elementRef = (0, vue.ref)(null);
1845
1862
  if (defaultValue !== void 0 && form.getValues(name) === void 0) form.setValue(name, defaultValue);
1863
+ const watchedValue = form.watch(name);
1846
1864
  const value = (0, vue.computed)({
1847
- get: () => {
1848
- return form.getValues(name) ?? defaultValue;
1849
- },
1865
+ get: () => watchedValue.value ?? defaultValue,
1850
1866
  set: (newValue) => {
1851
1867
  form.setValue(name, newValue);
1852
1868
  }
@@ -1905,6 +1921,19 @@ function useFormState(options = {}) {
1905
1921
  return { [name]: fullState[name] };
1906
1922
  });
1907
1923
  }
1924
+ function useFieldError(options) {
1925
+ const form = options.control ?? useFormContext();
1926
+ return (0, vue.computed)(() => {
1927
+ const error = get(form.formState.value.errors, options.name);
1928
+ if (!error) return void 0;
1929
+ if (typeof error === "string") return error;
1930
+ if (Array.isArray(error)) {
1931
+ if (__DEV__) console.warn(`[vue-hook-form] useFieldError('${options.name}') resolved to an array of per-item errors.\nuseFieldError only returns scalar error messages. Use formState.value.errors['${options.name}'] directly for item-level errors.`);
1932
+ return;
1933
+ }
1934
+ if (typeof error === "object" && "message" in error) return error.message;
1935
+ });
1936
+ }
1908
1937
  function isFieldError(error) {
1909
1938
  return typeof error === "object" && error !== null && "type" in error && "message" in error && typeof error.type === "string" && typeof error.message === "string";
1910
1939
  }
@@ -1916,7 +1945,10 @@ exports.provideForm = provideForm;
1916
1945
  exports.set = set;
1917
1946
  exports.unset = unset;
1918
1947
  exports.useController = useController;
1948
+ exports.useFieldError = useFieldError;
1919
1949
  exports.useForm = useForm;
1920
1950
  exports.useFormContext = useFormContext;
1921
1951
  exports.useFormState = useFormState;
1922
1952
  exports.useWatch = useWatch;
1953
+
1954
+ //# sourceMappingURL=vuehookform.cjs.map