Dynamic Form Validation with Live Feedback
Dynamic Form Validation with Live Feedback
Code
/**
* Dynamic Form Validation with Live Feedback (vanilla JS).
* Reusable validator that provides live, accessible error messages and blocks invalid submits.
*/
(() => {
'use strict';
const createFormValidator = (form, options = {}) => {
if (!(form instanceof HTMLFormElement)) throw new TypeError('Expected a <form> element.');
const config = {
fieldSelector: 'input, textarea, select',
errorClass: 'field-error',
invalidClass: 'is-invalid',
validClass: 'is-valid',
debounceMs: 120, // small debounce to avoid excessive validation on fast typing
messages: {
required: 'This field is required.',
email: 'Please enter a valid email.',
pattern: 'Please match the requested format.',
minLength: (n) => `Please enter at least ${n} characters.`,
maxLength: (n) => `Please enter no more than ${n} characters.`,
},
...options,
messages: { ...options.messages, ...options.messages }, // keep messages extensible
};
const fields = Array.from(form.querySelectorAll(config.fieldSelector))
.filter((el) => !el.disabled && el.type !== 'submit' && el.type !== 'button');
const getOrCreateErrorNode = (field) => {
const id = field.id || (field.name ? `${field.name}-field` : '');
const errorId = `${id || 'field'}-error`;
// Prefer an existing sibling error element if present; otherwise create one.
let errorEl = field.nextElementSibling?.classList?.contains(config.errorClass)
? field.nextElementSibling
: null;
if (!errorEl) {
errorEl = document.createElement('div');
errorEl.className = config.errorClass;
field.insertAdjacentElement('afterend', errorEl);
}
errorEl.id = errorEl.id || errorId;
errorEl.setAttribute('role', 'alert');
errorEl.setAttribute('aria-live', 'polite');
return errorEl;
};
const setFieldState = (field, { valid, message = '' }) => {
const errorEl = getOrCreateErrorNode(field);
errorEl.textContent = message;
// Accessibility: connect the input to its error message when invalid.
if (!valid && message) field.setAttribute('aria-describedby', errorEl.id);
else field.removeAttribute('aria-describedby');
field.setAttribute('aria-invalid', String(!valid));
field.classList.toggle(config.invalidClass, !valid);
field.classList.toggle(config.validClass, valid && field.value.trim().length > 0);
};
const validateField = (field) => {
try {
const value = field.value?.trim?.() ?? '';
const { required, minLength, maxLength, pattern } = field;
// Skip validation if field is optional and empty.
if (!required && value === '') return setFieldState(field, { valid: true }), true;
if (required && value === '') {
setFieldState(field, { valid: false, message: config.messages.required });
return false;
}
if (field.type === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
setFieldState(field, { valid: false, message: config.messages.email });
return false;
}
if (Number.isFinite(minLength) && minLength > 0 && value.length < minLength) {
setFieldState(field, { valid: false, message: config.messages.minLength(minLength) });
return false;
}
if (Number.isFinite(maxLength) && maxLength > 0 && value.length > maxLength) {
setFieldState(field, { valid: false, message: config.messages.maxLength(maxLength) });
return false;
}
if (pattern) {
const re = pattern instanceof RegExp ? pattern : new RegExp(`^(?:${pattern})$`);
if (value && !re.test(value)) {
setFieldState(field, { valid: false, message: config.messages.pattern });
return false;
}
}
setFieldState(field, { valid: true });
return true;
} catch (err) {
// Fail safe: if something goes wrong, mark invalid and keep user in control.
console.error('Validation error:', err);
setFieldState(field, { valid: false, message: 'Unable to validate this field.' });
return false;
}
};
const debounce = (fn, ms) => {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
};
const validateAll = async () => {
// Async-ready (e.g., later you can add server checks) while staying fast today.
const results = await Promise.all(fields.map(async (f) => validateField(f)));
return results.every(Boolean);
};
const onInput = debounce((e) => validateField(e.target), config.debounceMs);
const onBlur = (e) => validateField(e.target);
fields.forEach((field) => {
field.addEventListener('input', onInput, { passive: true });
field.addEventListener('blur', onBlur, { passive: true });
// Initialize aria + error node for consistent layout and a11y.
getOrCreateErrorNode(field);
field.setAttribute('aria-invalid', 'false');
});
form.addEventListener('submit', async (e) => {
const isValid = await validateAll();
if (!isValid) {
e.preventDefault();
// Focus the first invalid field for better UX.
const firstInvalid = fields.find((f) => f.classList.contains(config.invalidClass));
firstInvalid?.focus?.();
}
});
return {
validateField,
validateAll,
destroy: () => {
fields.forEach((field) => {
field.removeEventListener('input', onInput);
field.removeEventListener