Валидация формы в реальном времени

Зачем нужно

Валидация формы в реальном времени даёт пользователю мгновенную обратную связь при заполнении полей. Вместо сообщения об ошибке после нажатия "Отправить" пользователь видит подсказки прямо во время ввода. Это снижает количество ошибок и улучшает UX.

Где используется

  • Формы регистрации и авторизации
  • Формы оформления заказа
  • Профиль пользователя (редактирование)
  • Контактные формы
  • Любые формы, где важен корректный ввод

Стратегии валидации

Стратегия Когда срабатывает Когда применять
На blur При уходе с поля Основная стратегия -- не мешает вводу
На input При каждом символе Для мгновенного отклика (пароль)
На submit При отправке формы Финальная проверка
Debounced input Через N мс после ввода API-проверки (email, username)

Рекомендуемый подход: валидация на blur + повторная валидация на input после первой ошибки.


HTML с Constraint Validation API

HTML

<form class="form" id="registrationForm" novalidate>
  <div class="form-field">
    <label for="username" class="form-field__label">
      Имя пользователя <span class="required">*</span>
    </label>
    <input type="text" id="username" name="username" class="form-field__input"
           required minlength="3" maxlength="20"
           pattern="^[a-zA-Z0-9_]+$"
           autocomplete="username" />
    <span class="form-field__error" aria-live="polite"></span>
    <span class="form-field__hint">3-20 символов, буквы, цифры и _</span>
  </div>

  <div class="form-field">
    <label for="email" class="form-field__label">
      Email <span class="required">*</span>
    </label>
    <input type="email" id="email" name="email" class="form-field__input"
           required autocomplete="email" />
    <span class="form-field__error" aria-live="polite"></span>
  </div>

  <div class="form-field">
    <label for="password" class="form-field__label">
      Пароль <span class="required">*</span>
    </label>
    <input type="password" id="password" name="password" class="form-field__input"
           required minlength="8"
           autocomplete="new-password" />
    <span class="form-field__error" aria-live="polite"></span>
    <div class="password-strength" id="passwordStrength">
      <div class="password-strength__bar"></div>
      <span class="password-strength__text"></span>
    </div>
  </div>

  <div class="form-field">
    <label for="confirmPassword" class="form-field__label">
      Подтверждение пароля <span class="required">*</span>
    </label>
    <input type="password" id="confirmPassword" name="confirmPassword"
           class="form-field__input" required autocomplete="new-password" />
    <span class="form-field__error" aria-live="polite"></span>
  </div>

  <button type="submit" class="btn btn--primary" id="submitBtn">
    Зарегистрироваться
  </button>
</form>

CSS

.form-field {
  margin-bottom: 20px;
}

.form-field__label {
  display: block;
  margin-bottom: 6px;
  font-weight: 500;
  font-size: 14px;
}

.required {
  color: #e53e3e;
}

.form-field__input {
  width: 100%;
  padding: 10px 12px;
  border: 2px solid #ddd;
  border-radius: 8px;
  font-size: 16px;
  transition: border-color 0.2s, box-shadow 0.2s;
  outline: none;
}

.form-field__input:focus {
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}

/* Состояния валидации */
.form-field__input.valid {
  border-color: #22c55e;
}

.form-field__input.valid:focus {
  box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15);
}

.form-field__input.invalid {
  border-color: #e53e3e;
}

.form-field__input.invalid:focus {
  box-shadow: 0 0 0 3px rgba(229, 62, 62, 0.15);
}

/* Сообщение об ошибке */
.form-field__error {
  display: block;
  margin-top: 4px;
  font-size: 13px;
  color: #e53e3e;
  min-height: 20px;
}

/* Подсказка */
.form-field__hint {
  display: block;
  margin-top: 4px;
  font-size: 12px;
  color: #999;
}

/* Индикатор силы пароля */
.password-strength {
  margin-top: 8px;
}

.password-strength__bar {
  height: 4px;
  border-radius: 2px;
  background: #e0e0e0;
  transition: width 0.3s, background 0.3s;
}

.password-strength__bar[data-strength="weak"] {
  width: 33%;
  background: #e53e3e;
}

.password-strength__bar[data-strength="medium"] {
  width: 66%;
  background: #eab308;
}

.password-strength__bar[data-strength="strong"] {
  width: 100%;
  background: #22c55e;
}

.password-strength__text {
  font-size: 12px;
  margin-top: 4px;
}

JavaScript

class FormValidator {
  constructor(form) {
    this.form = form;
    this.fields = new Map();
    this.touched = new Set(); // Поля, с которых пользователь уже ушёл

    this.init;
  }

  init {
    // Собираем все поля с валидацией
    this.form.querySelectorAll('[required], [pattern], [minlength]').forEach((input) => {
      this.fields.set(input.name, {
        input,
        errorEl: input.parentElement.querySelector('.form-field__error'),
      });

      // Валидация при уходе с поля
      input.addEventListener('blur', () => {
        this.touched.add(input.name);
        this.validateField(input);
      });

      // Повторная валидация при вводе (после первого blur)
      input.addEventListener('input', () => {
        if (this.touched.has(input.name)) {
          this.validateField(input);
        }
      });
    });

    // Кастомные валидаторы
    this.setupPasswordStrength;
    this.setupPasswordMatch;

    // Валидация при отправке
    this.form.addEventListener('submit', (e) => this.onSubmit(e));
  }

  validateField(input) {
    const field = this.fields.get(input.name);
    if (!field) return true;

    // Очистить кастомные ошибки
    input.setCustomValidity('');

    // Кастомные проверки
    if (input.name === 'confirmPassword') {
      this.checkPasswordMatch(input);
    }

    const isValid = input.checkValidity;

    if (isValid) {
      input.classList.remove('invalid');
      input.classList.add('valid');
      field.errorEl.textContent = '';
    } else {
      input.classList.remove('valid');
      input.classList.add('invalid');
      field.errorEl.textContent = this.getErrorMessage(input);
    }

    return isValid;
  }

  getErrorMessage(input) {
    const validity = input.validity;

    if (validity.valueMissing) {
      return 'Это поле обязательно для заполнения';
    }
    if (validity.typeMismatch) {
      if (input.type === 'email') return 'Введите корректный email';
      return 'Некорректный формат';
    }
    if (validity.tooShort) {
      return `Минимум ${input.minLength} символов (сейчас ${input.value.length})`;
    }
    if (validity.tooLong) {
      return `Максимум ${input.maxLength} символов`;
    }
    if (validity.patternMismatch) {
      if (input.name === 'username') {
        return 'Только буквы, цифры и _';
      }
      return 'Некорректный формат';
    }
    if (validity.customError) {
      return input.validationMessage;
    }
    return 'Некорректное значение';
  }

  // Индикатор силы пароля
  setupPasswordStrength {
    const password = this.form.querySelector('#password');
    const strengthBar = this.form.querySelector('.password-strength__bar');
    const strengthText = this.form.querySelector('.password-strength__text');
    if (!password || !strengthBar) return;

    password.addEventListener('input', () => {
      const strength = this.calculateStrength(password.value);
      strengthBar.dataset.strength = strength.level;
      strengthText.textContent = strength.text();
    });
  }

  calculateStrength(password) {
    let score = 0;
    if (password.length >= 8) score++;
    if (password.length >= 12) score++;
    if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
    if (/\d/.test(password)) score++;
    if (/[^a-zA-Z0-9]/.test(password)) score++;

    if (score <= 2) return { level: 'weak', text: 'Слабый пароль' };
    if (score <= 3) return { level: 'medium', text: 'Средний пароль' };
    return { level: 'strong', text: 'Надёжный пароль' };
  }

  // Совпадение паролей
  setupPasswordMatch {
    const confirm = this.form.querySelector('#confirmPassword');
    if (!confirm) return;

    confirm.addEventListener('input', () => {
      if (this.touched.has('confirmPassword')) {
        this.checkPasswordMatch(confirm);
        this.validateField(confirm);
      }
    });
  }

  checkPasswordMatch(confirmInput) {
    const password = this.form.querySelector('#password');
    if (password && confirmInput.value !== password.value) {
      confirmInput.setCustomValidity('Пароли не совпадают');
    } else {
      confirmInput.setCustomValidity('');
    }
  }

  onSubmit(e) {
    e.preventDefault();

    // Отметить все поля как touched
    this.fields.forEach((_, name) => this.touched.add(name));

    // Валидировать все поля
    let isValid = true;
    this.fields.forEach(({ input }) => {
      if (!this.validateField(input)) {
        isValid = false;
      }
    });

    if (isValid) {
      const data = new FormData(this.form);
      console.log('Форма валидна, отправляем:', Object.fromEntries(data));
      // fetch('/api/register', { method: 'POST', body: data });
    } else {
      // Фокус на первое невалидное поле
      const firstInvalid = this.form.querySelector('.invalid');
      firstInvalid?.focus();
    }
  }
}

// Инициализация
new FormValidator(document.getElementById('registrationForm'));

Debounced валидация (для API-проверок)

function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout( => fn(...args), delay);
  };
}

// Проверка уникальности username через API
const checkUsername = debounce(async (input, errorEl) => {
  if (input.value.length < 3) return;

  try {
    const res = await fetch(`/api/check-username?name=${input.value}`);
    const { available } = await res.json();

    if (!available) {
      input.setCustomValidity('Это имя уже занято');
      input.classList.add('invalid');
      errorEl.textContent = 'Это имя уже занято';
    } else {
      input.setCustomValidity('');
    }
  } catch (err) {
    // При ошибке сети не блокируем отправку
    input.setCustomValidity('');
  }
}, 500);

Нативные CSS-псевдоклассы

/* Работают только с нативной валидацией (без novalidate) */
input:valid {
  border-color: green;
}

input:invalid {
  border-color: red;
}

/* Показывать ошибку только после взаимодействия */
input:not(:placeholder-shown):invalid {
  border-color: red;
}

/* Или через :user-invalid (Chrome 119+) */
input:user-invalid {
  border-color: red;
}

Constraint Validation API

Метод / свойство Описание
input.validity Объект с флагами валидности
input.checkValidity Проверяет валидность, возвращает boolean
input.reportValidity Проверяет и показывает нативный tooltip
input.setCustomValidity(msg) Устанавливает кастомную ошибку
input.validationMessage Текст текущей ошибки
validity.valueMissing Поле required пустое
validity.typeMismatch Не совпадает с type (email, url)
validity.patternMismatch Не совпадает с pattern
validity.tooShort / tooLong Нарушение min/maxlength

Частые ошибки

Ошибка Проблема Решение
Ошибка при каждом символе Раздражает пользователя Валидация на blur, потом на input
Нет aria-live на ошибке Скринридер не озвучит ошибку aria-live="polite"
Забыли novalidate Нативные тултипы мешают кастомным Добавь novalidate к form
Нет фокуса на первое ошибочное поле Пользователь не видит ошибку firstInvalid.focus()
API-валидация без debounce Слишком много запросов Debounce 300-500ms

Связанные темы

Ресурсы