Custom Form Validation with Feedback
Create a reusable form validation system that provides user feedback for valid and invalid inputs.
Code
/**
* Custom Form Validation with Feedback
* Reusable validator that applies HTML5 constraint feedback + custom rules before submit.
* Usage: attachFormValidator(document.querySelector('#myForm'), { onSubmit: async (data) => {...} });
*/
(() => {
'use strict';
// Function to validate form inputs (extends the provided baseline with better feedback + edge cases)
const validateForm = (form) => {
if (!(form instanceof HTMLFormElement)) throw new TypeError('validateForm(form) expects an HTMLFormElement');
let isValid = true;
const inputs = form.querySelectorAll('input, textarea, select');
inputs.forEach((input) => {
// Reset previous custom error; required for accurate re-validation
input.setCustomValidity('');
// Skip disabled, non-submittable, or no-name controls
if (input.disabled || !input.name) return;
const value = (input.value ?? '').trim();
// Check for empty fields
if (input.required && !value) {
input.setCustomValidity('This field cannot be empty');
isValid = false;
}
// Custom validation for email (only if user typed something)
if (input.type === 'email' && value) {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(value)) {
input.setCustomValidity('Please enter a valid email address');
isValid = false;
}
}
// Ensure built-in constraints (minlength, pattern, etc.) are surfaced
if (!input.checkValidity()) isValid = false;
});
// Show a single, native validation bubble on the first invalid control for best UX
if (!isValid) form.reportValidity();
return isValid;
};
const serializeForm = (form) => {
const data = new FormData(form);
// Functional reduction into a plain object; supports multi-value fields (e.g., checkboxes)
return [...data.entries()].reduce((acc, [key, val]) => {
const value = typeof val === 'string' ? val.trim() : val;
acc[key] = key in acc ? [].concat(acc[key], value) : value;
return acc;
}, {});
};
const attachFormValidator = (form, { onSubmit } = {}) => {
if (!(form instanceof HTMLFormElement)) throw new TypeError('attachFormValidator expects an HTMLFormElement');
// Input-level live feedback: validates on blur and clears error on input
const onBlur = (event) => {
const control = event.target;
if (!(control instanceof HTMLElement) || !form.contains(control)) return;
// Validate whole form to keep cross-field rules extensible and consistent
try { validateForm(form); } catch { /* no-op: avoid breaking UX */ }
};
const onInput = (event) => {
const control = event.target;
if (!(control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement || control instanceof HTMLSelectElement)) return;
// Clearing custom validity allows native messages to update as user types
control.setCustomValidity('');
};
// Event listener for form submission (supports async submit handlers safely)
const onSubmitHandler = async (event) => {
try {
if (!validateForm(form)) {
event.preventDefault();
return;
}
if (typeof onSubmit === 'function') {
event.preventDefault(); // allow custom async submission
const payload = serializeForm(form);
await onSubmit(payload, form);
}
} catch (error) {
event.preventDefault();
console.error('Form submission failed:', error);
alert('Something went wrong. Please try again.');
}
};
form.addEventListener('submit', onSubmitHandler);
form.addEventListener('blur', onBlur, true); // capture: handle children reliably
form.addEventListener('input', onInput);
// Return cleanup to avoid leaks in SPA-like environments
return () => {
form.removeEventListener('submit', onSubmitHandler);
form.removeEventListener('blur', onBlur, true);
form.removeEventListener('input', onInput);
};
};
// Example usage:
// const cleanup = attachFormValidator(document.querySelector('#myForm'), {
// onSubmit: async (data) => fetch('/api/submit', { method: 'POST', body: JSON.stringify(data) })
// });
window.attachFormValidator = attachFormValidator; // expose a single, intentional API surface
})();