import { ref, reactive, computed, toRaw, watch, type Ref } from 'vue' import { validateFieldValue, type FieldRules } from './useFormValidation' export type { FieldRules, ValidationRule } from './useFormValidation' /** * Field validation rules configuration. */ export interface FieldState { value: unknown error: string | null touched: boolean dirty: boolean } /** * Form state and methods. */ export interface UseFormReturn> { // Form data (reactive) data: T // Field errors errors: Record // Field touched state touched: Record // Field dirty state (value changed from initial) dirty: Record // Overall form state isValid: Ref isDirty: Ref isSubmitting: Ref // Methods setFieldValue: (field: K, value: T[K]) => void setFieldError: (field: string, error: string | null) => void setFieldTouched: (field: string, touched?: boolean) => void validateField: (field: string) => boolean validate: () => boolean reset: (values?: Partial) => void /** Replace the full form state and treat the provided values as the new clean baseline. */ replaceState: (values: T) => void handleSubmit: (onSubmit: (data: T) => Promise | void) => (e?: Event) => Promise getFieldProps: (field: K) => { modelValue: T[K] 'onUpdate:modelValue': (value: T[K]) => void onBlur: () => void error: string | null } } /** * Form state management composable with validation. * * @param initialValues - Initial form values * @param rules - Validation rules for each field * * @example * ```typescript * const { data, errors, isValid, handleSubmit, getFieldProps } = useForm( * { email: '', password: '' }, * { * email: { required: true, email: true }, * password: { required: true, minLength: 8 }, * } * ) * * // In template * * * Submit * ``` */ /** Reactive form state with field-level validation, dirty tracking, and submit handling. */ export function useForm>( initialValues: T, rules: Partial> = {} ): UseFormReturn { const cloneableInitialValues = deepToRaw(initialValues) as T // Deep copy initial values so nested objects are not shared let _initialValues = structuredClone(cloneableInitialValues) // Reactive form data const data = reactive(structuredClone(cloneableInitialValues)) as T // Field state - use simple Record types for better TS compatibility const errors = reactive>( Object.keys(initialValues).reduce((acc, key) => { acc[key] = null return acc }, {} as Record) ) const touched = reactive>( Object.keys(initialValues).reduce((acc, key) => { acc[key] = false return acc }, {} as Record) ) const dirty = reactive>( Object.keys(initialValues).reduce((acc, key) => { acc[key] = false return acc }, {} as Record) ) const isSubmitting = ref(false) // Watch data changes to track dirty state watch( () => ({ ...data }), (newData) => { for (const key of Object.keys(newData)) { dirty[key] = newData[key as keyof T] !== _initialValues[key as keyof T] } }, { deep: true } ) // Validate a single field function validateField(field: string): boolean { const value = data[field as keyof T] const fieldRules = rules[field as keyof T] const error = validateFieldValue(value, fieldRules, data as Record) errors[field] = error return error === null } // Validate all fields function validate(): boolean { let isAllValid = true for (const field of Object.keys(data)) { if (!validateField(field)) { isAllValid = false } } return isAllValid } // Computed overall validity const isValid = computed(() => { return Object.values(errors).every(error => error === null) }) // Computed overall dirty state const isDirty = computed(() => { return Object.values(dirty).some(d => d) }) // Set field value function setFieldValue(field: K, value: T[K]): void { ;(data as Record)[field as string] = value if (touched[field as string]) { validateField(field as string) } } // Set field error function setFieldError(field: string, error: string | null): void { errors[field] = error } // Set field touched function setFieldTouched(field: string, isTouched = true): void { touched[field] = isTouched if (isTouched) { validateField(field) } } // Reset form function reset(values?: Partial): void { const resetValues = values ? { ..._initialValues, ...values } : _initialValues for (const key of Object.keys(data)) { ;(data as Record)[key] = structuredClone(deepToRaw(resetValues[key as keyof T])) errors[key] = null touched[key] = false dirty[key] = false } } function replaceState(values: T): void { const nextValues = structuredClone(deepToRaw(values)) as T _initialValues = structuredClone(nextValues) for (const key of Object.keys(data)) { if (key in nextValues) continue delete (data as Record)[key] delete errors[key] delete touched[key] delete dirty[key] } for (const key of Object.keys(nextValues)) { ;(data as Record)[key] = structuredClone(deepToRaw(nextValues[key as keyof T])) errors[key] = null touched[key] = false dirty[key] = false } } // Handle submit function handleSubmit(onSubmit: (data: T) => Promise | void) { return async (e?: Event): Promise => { e?.preventDefault() // Mark all fields as touched for (const field of Object.keys(data)) { touched[field] = true } if (!validate()) { return } isSubmitting.value = true try { await onSubmit(data) } finally { isSubmitting.value = false } } } // Get props for a field (for v-bind) function getFieldProps(field: K) { const fieldStr = field as string return { modelValue: data[field], 'onUpdate:modelValue': (value: T[K]) => setFieldValue(field, value), onBlur: () => setFieldTouched(fieldStr), error: touched[fieldStr] ? errors[fieldStr] : null, } } return { data, errors, touched, dirty, isValid, isDirty, isSubmitting, setFieldValue, setFieldError, setFieldTouched, validateField, validate, reset, replaceState, handleSubmit, getFieldProps, } } function deepToRaw(value: unknown): unknown { const raw = toRaw(value) if (Array.isArray(raw)) { return raw.map(item => deepToRaw(item)) } if (isPlainRecord(raw)) { return Object.fromEntries( Object.entries(raw).map(([key, item]) => [key, deepToRaw(item)]) ) } return raw } function isPlainRecord(value: unknown): value is Record { return Object.prototype.toString.call(value) === '[object Object]' }