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
JavaScript
/**
* 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