UNPKG

ecfr-navigator

Version:

A lightweight, reusable Vue 3 component with Pinia integration for navigating hierarchical eCFR-style content in existing Vue applications.

311 lines (268 loc) 8.74 kB
import { ref, computed, reactive } from 'vue'; /** * Form validation composable for comprehensive form state management * Provides validation rules, error handling, and form state utilities */ export function useFormValidation(initialData = {}) { // Form data and state const formData = reactive({ ...initialData }); const errors = ref({}); const touched = ref({}); const isSubmitting = ref(false); const isValidating = ref(false); // Validation rules registry const validationRules = ref({}); const asyncValidators = ref({}); /** * Register validation rules for fields * @param {Object} rules - Field validation rules */ const setValidationRules = (rules) => { Object.assign(validationRules.value, rules); }; /** * Register async validators (e.g., for API calls) * @param {Object} validators - Async validator functions */ const setAsyncValidators = (validators) => { Object.assign(asyncValidators.value, validators); }; /** * Built-in validation rules */ const validators = { required: (value, message = 'This field is required') => { if (Array.isArray(value)) return value.length > 0 || message; return (value !== null && value !== undefined && value !== '') || message; }, email: (value, message = 'Please enter a valid email address') => { if (!value) return true; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(value) || message; }, phone: (value, message = 'Please enter a valid phone number') => { if (!value) return true; const phoneRegex = /^[\+]?[1-9][\d]{0,15}$/; return phoneRegex.test(value.replace(/\D/g, '')) || message; }, ssn: (value, message = 'Please enter a valid SSN') => { if (!value) return true; const ssnRegex = /^\d{3}-?\d{2}-?\d{4}$/; return ssnRegex.test(value) || message; }, minLength: (min) => (value, message = `Minimum ${min} characters required`) => { if (!value) return true; return value.length >= min || message; }, maxLength: (max) => (value, message = `Maximum ${max} characters allowed`) => { if (!value) return true; return value.length <= max || message; }, minValue: (min) => (value, message = `Value must be at least ${min}`) => { if (value === null || value === undefined || value === '') return true; return Number(value) >= min || message; }, maxValue: (max) => (value, message = `Value must be at most ${max}`) => { if (value === null || value === undefined || value === '') return true; return Number(value) <= max || message; }, pattern: (regex, message = 'Invalid format') => (value) => { if (!value) return true; return regex.test(value) || message; }, custom: (validatorFn) => validatorFn }; /** * Validate a single field * @param {string} fieldName - Field to validate * @returns {Promise<boolean>} - Validation result */ const validateField = async (fieldName) => { const rules = validationRules.value[fieldName]; if (!rules) return true; const value = getFieldValue(fieldName); const fieldErrors = []; // Run synchronous validations for (const rule of rules) { const result = await rule(value); if (result !== true) { fieldErrors.push(result); break; // Stop at first error } } // Run async validations if sync validations pass if (fieldErrors.length === 0 && asyncValidators.value[fieldName]) { try { const asyncResult = await asyncValidators.value[fieldName](value); if (asyncResult !== true) { fieldErrors.push(asyncResult); } } catch (error) { fieldErrors.push('Validation error occurred'); } } // Update errors if (fieldErrors.length > 0) { errors.value[fieldName] = fieldErrors[0]; return false; } else { delete errors.value[fieldName]; return true; } }; /** * Validate entire form * @returns {Promise<boolean>} - Overall validation result */ const validateForm = async () => { isValidating.value = true; const validationPromises = Object.keys(validationRules.value).map(validateField); const results = await Promise.all(validationPromises); isValidating.value = false; return results.every(result => result === true); }; /** * Get nested field value using dot notation * @param {string} fieldPath - Field path (e.g., 'user.address.street') * @returns {any} - Field value */ const getFieldValue = (fieldPath) => { return fieldPath.split('.').reduce((obj, key) => obj?.[key], formData); }; /** * Set nested field value using dot notation * @param {string} fieldPath - Field path * @param {any} value - New value */ const setFieldValue = (fieldPath, value) => { const keys = fieldPath.split('.'); const lastKey = keys.pop(); const target = keys.reduce((obj, key) => { if (!obj[key]) obj[key] = {}; return obj[key]; }, formData); target[lastKey] = value; // Mark field as touched touched.value[fieldPath] = true; // Validate field on change if it has been touched if (touched.value[fieldPath]) { validateField(fieldPath); } }; /** * Mark field as touched * @param {string} fieldName - Field name */ const touchField = (fieldName) => { touched.value[fieldName] = true; validateField(fieldName); }; /** * Reset form to initial state */ const resetForm = () => { Object.assign(formData, initialData); errors.value = {}; touched.value = {}; isSubmitting.value = false; }; /** * Clear all errors */ const clearErrors = () => { errors.value = {}; }; /** * Clear specific field error * @param {string} fieldName - Field name */ const clearFieldError = (fieldName) => { delete errors.value[fieldName]; }; /** * Check if field has error * @param {string} fieldName - Field name * @returns {boolean} - Has error */ const hasFieldError = (fieldName) => { return !!errors.value[fieldName]; }; /** * Get field error message * @param {string} fieldName - Field name * @returns {string} - Error message */ const getFieldError = (fieldName) => { return errors.value[fieldName] || ''; }; /** * Check if field is touched * @param {string} fieldName - Field name * @returns {boolean} - Is touched */ const isFieldTouched = (fieldName) => { return !!touched.value[fieldName]; }; // Computed properties const isValid = computed(() => Object.keys(errors.value).length === 0); const hasErrors = computed(() => Object.keys(errors.value).length > 0); const touchedFields = computed(() => Object.keys(touched.value)); const errorFields = computed(() => Object.keys(errors.value)); /** * Form submission handler * @param {Function} submitFn - Submit function * @returns {Promise} - Submit result */ const handleSubmit = async (submitFn) => { isSubmitting.value = true; try { // Mark all fields as touched Object.keys(validationRules.value).forEach(field => { touched.value[field] = true; }); // Validate form const isFormValid = await validateForm(); if (!isFormValid) { isSubmitting.value = false; return { success: false, errors: errors.value }; } // Submit form const result = await submitFn(formData); isSubmitting.value = false; return { success: true, data: result }; } catch (error) { isSubmitting.value = false; return { success: false, error: error.message }; } }; return { // Data formData, errors: computed(() => errors.value), touched: computed(() => touched.value), // State isSubmitting: computed(() => isSubmitting.value), isValidating: computed(() => isValidating.value), isValid, hasErrors, touchedFields, errorFields, // Methods setValidationRules, setAsyncValidators, validateField, validateForm, getFieldValue, setFieldValue, touchField, resetForm, clearErrors, clearFieldError, hasFieldError, getFieldError, isFieldTouched, handleSubmit, // Validators validators }; }