/** * ThrillWiki Form Validation Module * * Provides client-side form validation helpers with HTMX integration. * Works with Alpine.js form components and Django form fields. * * Features: * - Debounced HTMX validation triggers * - Field state management (pristine, dirty, valid, invalid) * - Real-time validation feedback * - Integration with Alpine.js stores * - Accessible error announcements */ // ============================================================================= // Form Field State Management // ============================================================================= /** * Field state constants */ const FieldState = { PRISTINE: 'pristine', // Field has not been touched DIRTY: 'dirty', // Field has been modified VALIDATING: 'validating', // Field is being validated VALID: 'valid', // Field passed validation INVALID: 'invalid', // Field failed validation }; /** * FormValidator class for managing form validation state */ class FormValidator { constructor(formElement, options = {}) { this.form = formElement; this.options = { validateOnBlur: true, validateOnChange: true, debounceMs: 500, showSuccessState: true, scrollToFirstError: true, ...options, }; this.fields = new Map(); this.debounceTimers = new Map(); this.init(); } /** * Initialize form validation */ init() { if (!this.form) return; // Find all form fields const inputs = this.form.querySelectorAll('input, textarea, select'); inputs.forEach(input => { if (input.name) { this.registerField(input); } }); // Prevent default form submission if validation fails this.form.addEventListener('submit', (e) => { if (!this.validateAll()) { e.preventDefault(); if (this.options.scrollToFirstError) { this.scrollToFirstError(); } } }); } /** * Register a field for validation */ registerField(input) { const fieldName = input.name; this.fields.set(fieldName, { element: input, state: FieldState.PRISTINE, errors: [], touched: false, }); // Add event listeners if (this.options.validateOnBlur) { input.addEventListener('blur', () => this.onFieldBlur(fieldName)); } if (this.options.validateOnChange) { input.addEventListener('input', () => this.onFieldChange(fieldName)); } } /** * Handle field blur event */ onFieldBlur(fieldName) { const field = this.fields.get(fieldName); if (!field) return; field.touched = true; this.validateField(fieldName); } /** * Handle field change event with debouncing */ onFieldChange(fieldName) { const field = this.fields.get(fieldName); if (!field) return; field.state = FieldState.DIRTY; this.updateFieldUI(fieldName); // Debounce validation clearTimeout(this.debounceTimers.get(fieldName)); this.debounceTimers.set(fieldName, setTimeout(() => { if (field.touched) { this.validateField(fieldName); } }, this.options.debounceMs)); } /** * Validate a single field */ validateField(fieldName) { const field = this.fields.get(fieldName); if (!field) return true; field.state = FieldState.VALIDATING; this.updateFieldUI(fieldName); const input = field.element; const errors = []; // Built-in HTML5 validation if (!input.validity.valid) { if (input.validity.valueMissing) { errors.push(`${this.getFieldLabel(fieldName)} is required`); } if (input.validity.typeMismatch) { errors.push(`Please enter a valid ${input.type}`); } if (input.validity.tooShort) { errors.push(`Must be at least ${input.minLength} characters`); } if (input.validity.tooLong) { errors.push(`Must be no more than ${input.maxLength} characters`); } if (input.validity.patternMismatch) { errors.push(input.title || 'Please match the requested format'); } if (input.validity.rangeUnderflow) { errors.push(`Must be at least ${input.min}`); } if (input.validity.rangeOverflow) { errors.push(`Must be no more than ${input.max}`); } } // Custom validation rules from data attributes if (input.dataset.validateUrl && input.value) { try { new URL(input.value); } catch { errors.push('Please enter a valid URL'); } } if (input.dataset.validateMatch) { const matchField = this.form.querySelector(`[name="${input.dataset.validateMatch}"]`); if (matchField && input.value !== matchField.value) { errors.push(`Must match ${this.getFieldLabel(input.dataset.validateMatch)}`); } } // Update field state field.errors = errors; field.state = errors.length > 0 ? FieldState.INVALID : FieldState.VALID; this.updateFieldUI(fieldName); return errors.length === 0; } /** * Validate all fields */ validateAll() { let isValid = true; this.fields.forEach((field, fieldName) => { field.touched = true; if (!this.validateField(fieldName)) { isValid = false; } }); return isValid; } /** * Update field UI based on state */ updateFieldUI(fieldName) { const field = this.fields.get(fieldName); if (!field) return; const input = field.element; const wrapper = input.closest('.form-field'); const feedback = wrapper?.querySelector('.field-feedback'); // Remove all state classes input.classList.remove( 'border-red-500', 'border-green-500', 'border-gray-300', 'focus:ring-red-500', 'focus:ring-green-500', 'focus:ring-blue-500' ); // Set aria attributes input.setAttribute('aria-invalid', field.state === FieldState.INVALID); switch (field.state) { case FieldState.VALIDATING: input.classList.add('border-blue-300'); break; case FieldState.INVALID: input.classList.add('border-red-500', 'focus:ring-red-500'); if (feedback) { feedback.innerHTML = this.renderErrors(field.errors); } break; case FieldState.VALID: if (this.options.showSuccessState && field.touched) { input.classList.add('border-green-500', 'focus:ring-green-500'); if (feedback) { feedback.innerHTML = this.renderSuccess(); } } break; default: input.classList.add('border-gray-300', 'focus:ring-blue-500'); } } /** * Render error messages HTML */ renderErrors(errors) { if (errors.length === 0) return ''; return `
`; } /** * Render success indicator HTML */ renderSuccess() { return ` `; } /** * Get field label text */ getFieldLabel(fieldName) { const field = this.fields.get(fieldName); if (!field) return fieldName; const wrapper = field.element.closest('.form-field'); const label = wrapper?.querySelector('label'); return label?.textContent?.replace('*', '').trim() || fieldName; } /** * Scroll to first error */ scrollToFirstError() { for (const [fieldName, field] of this.fields) { if (field.state === FieldState.INVALID) { field.element.scrollIntoView({ behavior: 'smooth', block: 'center' }); field.element.focus(); break; } } } /** * Set external errors (from server) */ setServerErrors(errors) { Object.entries(errors).forEach(([fieldName, messages]) => { const field = this.fields.get(fieldName); if (field) { field.errors = Array.isArray(messages) ? messages : [messages]; field.state = FieldState.INVALID; field.touched = true; this.updateFieldUI(fieldName); } }); } /** * Clear all errors */ clearErrors() { this.fields.forEach((field, fieldName) => { field.errors = []; field.state = FieldState.PRISTINE; this.updateFieldUI(fieldName); }); } /** * Reset form to initial state */ reset() { this.clearErrors(); this.fields.forEach(field => { field.touched = false; }); this.form.reset(); } } // ============================================================================= // HTMX Validation Integration // ============================================================================= /** * Setup HTMX validation for a field * @param {HTMLElement} input - The input element * @param {string} validateUrl - URL for validation endpoint * @param {object} options - Configuration options */ function setupHTMXValidation(input, validateUrl, options = {}) { const defaults = { trigger: 'blur changed delay:500ms', indicator: true, targetSelector: null, }; const config = { ...defaults, ...options }; // Generate target ID const targetId = config.targetSelector || `#${input.name}-feedback`; // Set HTMX attributes input.setAttribute('hx-post', validateUrl); input.setAttribute('hx-trigger', config.trigger); input.setAttribute('hx-target', targetId); input.setAttribute('hx-swap', 'innerHTML'); if (config.indicator) { const indicatorId = `#${input.name}-indicator`; input.setAttribute('hx-indicator', indicatorId); // Create indicator if it doesn't exist const wrapper = input.closest('.form-field'); if (wrapper && !wrapper.querySelector(indicatorId)) { const indicator = document.createElement('span'); indicator.id = `${input.name}-indicator`; indicator.className = 'htmx-indicator absolute right-3 top-1/2 -translate-y-1/2'; indicator.innerHTML = ''; const inputWrapper = wrapper.querySelector('.relative') || wrapper; inputWrapper.appendChild(indicator); } } // Process HTMX attributes if (typeof htmx !== 'undefined') { htmx.process(input); } } // ============================================================================= // Alpine.js Integration // ============================================================================= /** * Alpine.js form validation component * Usage: x-data="formValidation('/api/validate/')" */ document.addEventListener('alpine:init', () => { if (typeof Alpine === 'undefined') return; Alpine.data('formValidation', (validateUrl = null) => ({ fields: {}, errors: {}, touched: {}, validating: {}, submitted: false, init() { // Find all form fields within this component this.$el.querySelectorAll('input, textarea, select').forEach(input => { if (input.name) { this.fields[input.name] = input.value; this.errors[input.name] = []; this.touched[input.name] = false; this.validating[input.name] = false; } }); }, async validateField(fieldName) { if (!validateUrl) return true; this.validating[fieldName] = true; try { const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || document.querySelector('[name=csrfmiddlewaretoken]')?.value; const response = await fetch(validateUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken, }, body: JSON.stringify({ field: fieldName, value: this.fields[fieldName], }), }); const data = await response.json(); if (data.errors && data.errors[fieldName]) { this.errors[fieldName] = data.errors[fieldName]; return false; } else { this.errors[fieldName] = []; return true; } } catch (error) { console.error('Validation error:', error); return true; // Don't block on network errors } finally { this.validating[fieldName] = false; } }, onBlur(fieldName) { this.touched[fieldName] = true; this.validateField(fieldName); }, hasError(fieldName) { return this.touched[fieldName] && this.errors[fieldName]?.length > 0; }, isValid(fieldName) { return this.touched[fieldName] && this.errors[fieldName]?.length === 0; }, getErrors(fieldName) { return this.errors[fieldName] || []; }, setServerErrors(errors) { Object.entries(errors).forEach(([field, messages]) => { this.errors[field] = Array.isArray(messages) ? messages : [messages]; this.touched[field] = true; }); }, clearErrors() { Object.keys(this.errors).forEach(field => { this.errors[field] = []; }); }, async validateAll() { let isValid = true; for (const fieldName of Object.keys(this.fields)) { this.touched[fieldName] = true; const fieldValid = await this.validateField(fieldName); if (!fieldValid) isValid = false; } return isValid; }, async submitForm(url, options = {}) { this.submitted = true; const isValid = await this.validateAll(); if (!isValid) { return { success: false, errors: this.errors }; } try { const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || document.querySelector('[name=csrfmiddlewaretoken]')?.value; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken, ...options.headers, }, body: JSON.stringify(this.fields), }); const data = await response.json(); if (!response.ok) { if (data.errors) { this.setServerErrors(data.errors); } return { success: false, errors: data.errors || data }; } return { success: true, data }; } catch (error) { console.error('Form submission error:', error); return { success: false, error: error.message }; } }, })); }); // ============================================================================= // Global Exports // ============================================================================= window.FormValidator = FormValidator; window.setupHTMXValidation = setupHTMXValidation; window.FieldState = FieldState;