JavaScript 75 views

Dynamic Form Validation with Live Feedback

Dynamic Form Validation with Live Feedback

By TWC Team • Feb 05, 2026

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
Back to Snippets