Smart Form Validation with Custom Messages
Implement a reusable form validation snippet for better user feedback on error states.
Code
/**
* Smart Form Validation with Custom Messages
* Reusable, accessible vanilla-JS validation that shows inline + summary errors and supports async rules.
* Usage: attachSmartValidation(document.querySelector('#myForm'), { customMessages, rules, onValidSubmit }).
*/
(() => {
'use strict';
const byId = (id) => document.getElementById(id);
const defaultMessageFor = (field, validity) => {
const label = field.getAttribute('data-label') || field.name || field.id || 'This field';
if (validity.valueMissing) return `${label} is required.`;
if (validity.typeMismatch) return `Please enter a valid ${field.type}.`;
if (validity.tooShort) return `${label} must be at least ${field.minLength} characters.`;
if (validity.patternMismatch) return `Please match the requested format for ${label}.`;
return `Please check ${label}.`;
};
const getFocusableFields = (form) =>
[...form.elements].filter((el) => el && el.name && !el.disabled && !['submit', 'button', 'fieldset'].includes(el.type));
const upsertInlineError = (field, message) => {
const id = `${field.id || field.name}-error`;
let node = byId(id);
if (!node) {
node = document.createElement('div');
node.id = id;
node.className = 'field-error';
node.setAttribute('role', 'alert'); // Screen readers announce changes
field.insertAdjacentElement('afterend', node);
}
node.textContent = message || '';
field.setAttribute('aria-invalid', message ? 'true' : 'false');
field.setAttribute('aria-describedby', message ? id : '');
};
const displayErrors = (errors, errorListEl) => {
if (!errorListEl) return;
errorListEl.innerHTML = '';
Object.values(errors).forEach((msg) => {
const li = document.createElement('li');
li.textContent = msg;
errorListEl.appendChild(li);
});
};
// Reusable form validation function (sync + optional async per-field rules)
const validateForm = async (form, { customMessages = {}, rules = {}, errorListEl = byId('error-list') } = {}) => {
try {
const errors = {};
const fields = getFocusableFields(form);
// Validate all fields; async rules run in parallel for performance.
const asyncChecks = fields.map(async (field) => {
const name = field.name;
const value = field.value?.trim?.() ?? '';
// Basic required check (mirrors native constraint validation)
if (field.required && !value) {
errors[name] = customMessages[name]?.required || `${name} is required!`;
return;
}
// Native constraint checks (type, minlength, pattern, etc.)
if (!field.checkValidity()) {
errors[name] = customMessages[name]?.invalid || defaultMessageFor(field, field.validity);
return;
}
// Optional async/sync custom rule: (value, field, form) => string | null | Promise<string|null>
if (typeof rules[name] === 'function') {
const result = await rules[name](value, field, form);
if (typeof result === 'string' && result.trim()) errors[name] = result.trim();
}
});
await Promise.all(asyncChecks);
// Inline errors + summary list
fields.forEach((field) => upsertInlineError(field, errors[field.name] || ''));
displayErrors(errors, errorListEl);
return Object.keys(errors).length === 0;
} catch (err) {
console.error('Validation error:', err);
displayErrors({ _form: 'Something went wrong while validating. Please try again.' }, byId('error-list'));
return false;
}
};
const attachSmartValidation = (form, options = {}) => {
if (!(form instanceof HTMLFormElement)) throw new TypeError('attachSmartValidation: form must be an HTMLFormElement.');
const onSubmit = async (e) => {
e.preventDefault(); // Prevent default to validate first
const isValid = await validateForm(form, options);
if (!isValid) return;
try {
// Optional async hook before submission (e.g., analytics, server-side precheck)
if (typeof options.onValidSubmit === 'function') await options.onValidSubmit(form);
form.submit(); // Proceed to submit the form
} catch (err) {
console.error('Submit hook error:', err);
displayErrors({ _submit: 'Unable to submit right now. Please try again.' }, options.errorListEl || byId('error-list'));
}
};
// Real-time feedback: validate the changed field only (fast path)
const onInput = (e) => {
const field = e.target;
if (!field?.name || field.disabled) return;
if (field.checkValidity()) upsertInlineError(field, ''); // Clear when valid; async rules run on submit to avoid spamming
};
form.addEventListener('submit', onSubmit);
form.addEventListener('input', onInput);
return () => {
form.removeEventListener('submit', onSubmit);
form.removeEventListener('input', onInput);
};
};
// Example usage:
// const detach = attachSmartValidation(document.getElementById('myForm'), {
// customMessages: { email: { required: 'Email is required.' } },
// rules: { email: async (value) => (value.endsWith('@example.com') ? null : 'Use an @example.com email.') },
// onValidSubmit: async () => { /* e.g., await fetch('/track', { method: 'POST' }) */ }
// });
// Auto-attach if present (keeps snippet drop-in friendly without leaking globals)
const form = byId('myForm');
if (form) attachSmartValidation(form);
})();