UNPKG

form-functionality-library

Version:

A modular, flexible form functionality library for Webflow forms supporting single-step, multi-step, and branching forms

490 lines 18.1 kB
/** * Form validation module with branch awareness */ import { SELECTORS, DEFAULTS } from '../config.js'; import { logVerbose, queryAllByAttr, queryByAttr, getAttrValue, debounce, getInputValue, isFormInput, isVisible } from './utils.js'; import { showError, clearError } from './errors.js'; import { showFieldError, clearFieldError } from './webflowNativeErrors.js'; import { formEvents } from './events.js'; let initialized = false; let eventCleanupFunctions = []; let fieldValidations = new Map(); let navigatedSteps = new Set(); // Track navigated steps locally /** * Initialize validation functionality */ export function initValidation(root = document) { if (initialized) { logVerbose('Validation already initialized, cleaning up first'); resetValidation(); } logVerbose('Initializing form validation'); // Find all form inputs const formInputs = queryAllByAttr('input, select, textarea', root); logVerbose(`Found ${formInputs.length} form inputs`); // Set up validation rules for each input setupFieldValidations(formInputs); // Set up event listeners setupValidationEventListeners(); initialized = true; formEvents.registerModule('validation'); logVerbose('Validation initialization complete'); } /** * Set up validation rules for form inputs */ function setupFieldValidations(inputs) { inputs.forEach(input => { if (!isFormInput(input)) return; const htmlInput = input; const fieldName = htmlInput.name || getAttrValue(input, 'data-step-field-name'); if (!fieldName) { logVerbose('Skipping field validation setup - no field name', { element: input, name: htmlInput.name, dataStepFieldName: getAttrValue(input, 'data-step-field-name'), id: htmlInput.id, type: htmlInput.type }); return; } const rules = extractValidationRules(input); if (rules.length === 0) { logVerbose(`No validation rules found for field: ${fieldName}`); return; } fieldValidations.set(fieldName, { element: input, rules, isValid: true }); logVerbose(`Validation rules set for field: ${fieldName}`, { rules: rules.map(r => r.type), rulesCount: rules.length, elementId: input.id, elementName: input.name, elementValue: input.value, elementTag: input.tagName }); }); } /** * Extract validation rules from input element */ function extractValidationRules(input) { const rules = []; // Required validation - check both 'required' and 'data-required' attributes if (input.hasAttribute('required') || input.hasAttribute('data-required')) { rules.push({ type: 'required', message: getAttrValue(input, 'data-error-message') || 'This field is required' }); } // Email validation if (input instanceof HTMLInputElement && input.type === 'email') { rules.push({ type: 'email', message: 'Please enter a valid email address' }); } // Phone validation if (input instanceof HTMLInputElement && input.type === 'tel') { rules.push({ type: 'phone', message: 'Please enter a valid phone number' }); } // Min length validation const minLength = getAttrValue(input, 'minlength'); if (minLength) { rules.push({ type: 'min', value: parseInt(minLength), message: `Minimum ${minLength} characters required` }); } // Max length validation const maxLength = getAttrValue(input, 'maxlength'); if (maxLength) { rules.push({ type: 'max', value: parseInt(maxLength), message: `Maximum ${maxLength} characters allowed` }); } // Pattern validation const pattern = getAttrValue(input, 'pattern'); if (pattern) { rules.push({ type: 'pattern', value: new RegExp(pattern), message: 'Please enter a valid format' }); } return rules; } /** * Set up validation event listeners - UPDATED to track navigation */ function setupValidationEventListeners() { // Listen to centralized field events instead of direct DOM events const cleanup1 = formEvents.on('field:input', (data) => { // Apply debouncing to input events debounce(() => handleFieldValidationEvent(data), DEFAULTS.VALIDATION_DELAY)(); }); const cleanup2 = formEvents.on('field:blur', handleFieldValidationEvent); const cleanup3 = formEvents.on('field:change', handleFieldValidationEvent); // NEW: Listen to step changes to track navigated steps const cleanup4 = formEvents.on('step:change', (data) => { if (data.currentStepId) { navigatedSteps.add(data.currentStepId); logVerbose(`Validation: Step ${data.currentStepId} marked as navigated`, { totalNavigatedSteps: navigatedSteps.size, navigatedStepsList: Array.from(navigatedSteps) }); } }); eventCleanupFunctions.push(cleanup1, cleanup2, cleanup3, cleanup4); logVerbose('Validation module subscribed to centralized field and step events'); } /** * Handle field validation events - UPDATED to clear errors on input and validate on interactions */ function handleFieldValidationEvent(data) { const { fieldName, element, eventType } = data; if (!fieldName) { logVerbose('Skipping validation - no field name found', { element, eventType }); return; } // NEW: Check if field is in a step that has been navigated to const stepWrapper = element.closest('.step_wrapper[data-answer]'); if (stepWrapper) { const stepId = getAttrValue(stepWrapper, 'data-answer'); if (stepId && !navigatedSteps.has(stepId)) { logVerbose(`Skipping validation for field in non-navigated step: ${fieldName}`, { stepId, navigatedSteps: Array.from(navigatedSteps), fieldInNavigatedStep: false, eventType }); return; } } // ENHANCED: Different handling for different event types if (eventType === 'input') { // On input events, check if field has errors and clear them if valid const fieldValidation = fieldValidations.get(fieldName); const hasVisualErrors = element.classList.contains('error-field'); // Validate if field has validation errors OR visual error styling if ((fieldValidation && !fieldValidation.isValid) || hasVisualErrors) { logVerbose(`Input event on error field, checking if valid: ${fieldName}`, { currentlyValid: fieldValidation?.isValid || 'no-validation-rules', hasVisualErrors, eventType }); // Validate to potentially clear errors const isNowValid = validateField(fieldName); if (isNowValid) { logVerbose(`Field error cleared on input: ${fieldName}`); } } else { logVerbose(`Input event on field without errors, skipping validation: ${fieldName}`, { hasValidationRules: !!fieldValidation, currentlyValid: fieldValidation?.isValid || 'no-rules', hasVisualErrors, eventType }); } } else if (eventType === 'blur' || eventType === 'change') { // On blur/change, always validate logVerbose(`Validating field on ${eventType}: ${fieldName}`); validateField(fieldName); } else { logVerbose(`Skipping validation for event type: ${eventType}`, { fieldName, eventType, reason: 'Event type not handled' }); } } /** * Validate a specific field */ export function validateField(fieldName) { const fieldValidation = fieldValidations.get(fieldName); if (!fieldValidation) { logVerbose(`No validation rules found for field: ${fieldName}`); return true; } // Get fresh element from DOM to avoid stale references const input = document.querySelector(`input[name="${fieldName}"], select[name="${fieldName}"], textarea[name="${fieldName}"]`) || fieldValidation.element; if (!input) { logVerbose(`No element found for field: ${fieldName}`); return true; } const value = getInputValue(input); logVerbose(`Validating field: ${fieldName}`, { value, elementExists: !!input }); for (const rule of fieldValidation.rules) { const { isValid, message } = validateRule(value, rule); if (!isValid) { fieldValidation.isValid = false; fieldValidation.errorMessage = message || 'Invalid field'; // Use both error systems for maximum compatibility showError(fieldName, fieldValidation.errorMessage); // Legacy system showFieldError(fieldName, fieldValidation.errorMessage); // New Webflow-native system updateFieldVisualState(input, false, fieldValidation.errorMessage); return false; } } // All rules passed fieldValidation.isValid = true; // Clear errors in both systems clearError(fieldName); // Legacy system clearFieldError(fieldName); // New Webflow-native system updateFieldVisualState(input, true); return true; } /** * Enhanced validation patterns for common use cases */ const VALIDATION_PATTERNS = { email: /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/, phone: /^[\+]?[1-9][\d]{0,15}$/, // International format phoneUS: /^(\+1\s?)?(\([0-9]{3}\)|[0-9]{3})[\s\-]?[0-9]{3}[\s\-]?[0-9]{4}$/, // US format url: /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/, zipCode: /^\d{5}(-\d{4})?$/, // US ZIP code zipCodeCA: /^[A-Za-z]\d[A-Za-z][ -]?\d[A-Za-z]\d$/, // Canadian postal code creditCard: /^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|3[0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12})$/, // Major credit cards ssn: /^\d{3}-?\d{2}-?\d{4}$/, // US SSN strongPassword: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/ // Strong password }; /** * Validate a single rule */ function validateRule(value, rule) { switch (rule.type) { case 'required': return { isValid: Array.isArray(value) ? value.length > 0 : !!value && String(value).trim() !== '', message: rule.message }; case 'email': // Enhanced email validation return { isValid: VALIDATION_PATTERNS.email.test(String(value)), message: rule.message || 'Please enter a valid email address' }; case 'phone': // Enhanced phone validation with multiple formats const phoneValue = String(value).replace(/[\s\-\(\)]/g, ''); // Remove formatting const isValidPhone = VALIDATION_PATTERNS.phone.test(phoneValue) || VALIDATION_PATTERNS.phoneUS.test(String(value)); return { isValid: isValidPhone, message: rule.message || 'Please enter a valid phone number' }; case 'min': if (typeof rule.value !== 'number') return { isValid: true }; return { isValid: String(value).length >= rule.value, message: rule.message || `Minimum ${rule.value} characters required` }; case 'max': if (typeof rule.value !== 'number') return { isValid: true }; return { isValid: String(value).length <= rule.value, message: rule.message || `Maximum ${rule.value} characters allowed` }; case 'pattern': if (!(rule.value instanceof RegExp)) return { isValid: true }; return { isValid: rule.value.test(String(value)), message: rule.message || 'Please enter a valid format' }; case 'custom': if (!rule.validator) return { isValid: true }; try { return { isValid: rule.validator(value), message: rule.message || 'Invalid value' }; } catch (error) { logVerbose('Error in custom validator', { error, rule }); return { isValid: false, message: 'Validation error occurred' }; } default: return { isValid: true }; } } /** * Update field visual state based on validation */ function updateFieldVisualState(input, isValid, errorMessage) { const fieldName = input.name || getAttrValue(input, 'data-step-field-name'); if (!fieldName) return; // Apply error state to input if (!isValid) { input.classList.add('error-field'); showError(fieldName, errorMessage); } else { input.classList.remove('error-field'); clearError(fieldName); } // Also apply error state to form-field_wrapper if present (new structure) const fieldWrapper = input.closest('.form-field_wrapper'); if (fieldWrapper) { if (!isValid) { fieldWrapper.classList.add('error-field'); } else { fieldWrapper.classList.remove('error-field'); } } } /** * Validate a specific step */ export function validateStep(stepId) { const stepElement = queryByAttr(`[data-answer="${stepId}"]`); if (!stepElement) { logVerbose(`Step not found with data-answer="${stepId}"`); return true; } // Check if step is visible if (!isVisible(stepElement)) { logVerbose(`Skipping validation for hidden step: ${stepId}`); return true; } logVerbose(`Validating step: ${stepId}`); const inputs = stepElement.querySelectorAll('input, select, textarea'); let isStepValid = true; inputs.forEach(input => { if (!isFormInput(input)) return; const fieldName = input.name || getAttrValue(input, 'data-step-field-name'); if (fieldName) { const isFieldValid = validateField(fieldName); if (!isFieldValid) { isStepValid = false; } } }); logVerbose(`Step validation result: ${stepId}`, { isValid: isStepValid }); return isStepValid; } /** * Validate all visible fields */ export function validateAllVisibleFields() { logVerbose('Validating all visible fields'); let allValid = true; const validationResults = {}; for (const [fieldName, fieldValidation] of fieldValidations) { // Check if field is in visible step const stepElement = fieldValidation.element.closest(SELECTORS.STEP); let shouldValidate = true; if (stepElement) { const stepId = getAttrValue(stepElement, 'data-answer'); if (stepId && !isVisible(stepElement)) { shouldValidate = false; } } if (shouldValidate) { const isValid = validateField(fieldName); validationResults[fieldName] = isValid; if (!isValid) { allValid = false; } } } logVerbose('All visible fields validation complete', { allValid, results: validationResults }); return allValid; } /** * Clear validation errors for a field */ export function clearFieldValidation(fieldName) { const fieldValidation = fieldValidations.get(fieldName); if (!fieldValidation) return; fieldValidation.isValid = true; fieldValidation.errorMessage = undefined; updateFieldVisualState(fieldValidation.element, true); logVerbose(`Cleared validation for field: ${fieldName}`); } /** * Clear validation errors for all fields */ export function clearAllValidation() { logVerbose('Clearing all field validation'); fieldValidations.forEach((validation, fieldName) => { clearFieldValidation(fieldName); }); } /** * Add custom validation rule to a field */ export function addCustomValidation(fieldName, validator, message) { const fieldValidation = fieldValidations.get(fieldName); if (!fieldValidation) { logVerbose(`Cannot add custom validation to unknown field: ${fieldName}`); return; } fieldValidation.rules.push({ type: 'custom', validator, message }); logVerbose(`Added custom validation to field: ${fieldName}`, { message }); } /** * Get validation state for debugging */ export function getValidationState() { return { initialized, fieldValidations: Array.from(fieldValidations.entries()).reduce((acc, [key, value]) => { acc[key] = { isValid: value.isValid, errorMessage: value.errorMessage, rules: value.rules.map(r => r.type) }; return acc; }, {}) }; } /** * Reset validation state and cleanup - UPDATED to clear navigated steps */ function resetValidation() { logVerbose('Resetting validation'); // Clean up event listeners eventCleanupFunctions.forEach(cleanup => cleanup()); eventCleanupFunctions = []; // Clear all validation states clearAllValidation(); // Reset field validations fieldValidations.clear(); // Clear navigated steps tracking navigatedSteps.clear(); initialized = false; logVerbose('Validation reset complete'); } //# sourceMappingURL=validation.js.map