476 lines
14 KiB
JavaScript
476 lines
14 KiB
JavaScript
/**
|
||
* @module liveValidationHandler
|
||
* @description Валидация формы в реальном времени с подсветкой ошибок.
|
||
* Проверяет поля по мере ввода и показывает feedback сразу.
|
||
*
|
||
* Использование:
|
||
* import { initLiveValidation } from './liveValidationHandler.js';
|
||
*
|
||
* initLiveValidation({
|
||
* formSelector: '#registrationForm',
|
||
* rules: {
|
||
* email: [
|
||
* { type: 'required', message: 'Email обязателен' },
|
||
* { type: 'email', message: 'Введите корректный email' }
|
||
* ],
|
||
* phone_number: [
|
||
* { type: 'required', message: 'Телефон обязателен' },
|
||
* { type: 'pattern', pattern: /^\+?\d{10,15}$/, message: 'Неверный формат телефона' }
|
||
* ],
|
||
* password1: [
|
||
* { type: 'required', message: 'Пароль обязателен' },
|
||
* { type: 'minLength', value: 6, message: 'Минимум 6 символов' }
|
||
* ],
|
||
* password2: [
|
||
* { type: 'required', message: 'Подтвердите пароль' },
|
||
* { type: 'match', field: 'password1', message: 'Пароли не совпадают' }
|
||
* ],
|
||
* gender: [
|
||
* { type: 'required', message: 'Укажите пол' }
|
||
* ]
|
||
* }
|
||
* });
|
||
*/
|
||
|
||
/**
|
||
* Санитизация телефонного номера: оставляет только цифры и +.
|
||
* @param {string} value - Исходное значение
|
||
* @returns {string} Очищенное значение
|
||
*/
|
||
function sanitizePhone(value) {
|
||
value = String(value || '');
|
||
let output = '';
|
||
|
||
for (let i = 0; i < value.length; i++) {
|
||
const char = value[i];
|
||
|
||
// Цифры пропускаем всегда
|
||
if (char >= '0' && char <= '9') {
|
||
output += char;
|
||
continue;
|
||
}
|
||
|
||
// Плюс только в начале
|
||
if (char === '+' && output.length === 0) {
|
||
output = '+';
|
||
}
|
||
}
|
||
|
||
return output;
|
||
}
|
||
|
||
/**
|
||
* Валидаторы для различных типов проверок
|
||
*/
|
||
const validators = {
|
||
required: (value, input) => {
|
||
// Специальная обработка для file input
|
||
if (input && input.type === 'file') {
|
||
return input.files && input.files.length > 0;
|
||
}
|
||
|
||
if (typeof value === 'string') {
|
||
return value.trim().length > 0;
|
||
}
|
||
return value !== null && value !== undefined && value !== '';
|
||
},
|
||
|
||
email: (value) => {
|
||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
return emailRegex.test(value);
|
||
},
|
||
|
||
pattern: (value, pattern) => {
|
||
return pattern.test(value);
|
||
},
|
||
|
||
minLength: (value, minLen) => {
|
||
return value.length >= minLen;
|
||
},
|
||
|
||
maxLength: (value, maxLen) => {
|
||
return value.length <= maxLen;
|
||
},
|
||
|
||
match: (value, formData, fieldName) => {
|
||
return value === formData[fieldName];
|
||
},
|
||
|
||
// Специальная валидация для контактов (хотя бы один)
|
||
anyContact: (value, formData, fieldName, fields) => {
|
||
return fields.some(field => {
|
||
const val = formData[field];
|
||
return val && val.trim().length > 0;
|
||
});
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Проверяет поле на соответствие правилам
|
||
* @param {HTMLInputElement|HTMLSelectElement} input - Элемент поля
|
||
* @param {Array} rules - Массив правил валидации
|
||
* @param {Object} formData - Данные всей формы
|
||
* @returns {Object} { valid: boolean, message: string }
|
||
*/
|
||
function validateField(input, rules, formData) {
|
||
if (!rules || rules.length === 0) {
|
||
return { valid: true, message: '' };
|
||
}
|
||
|
||
const value = input.value;
|
||
const fieldName = input.name;
|
||
|
||
for (const rule of rules) {
|
||
let isValid = true;
|
||
|
||
switch (rule.type) {
|
||
case 'required':
|
||
isValid = validators.required(value, input);
|
||
break;
|
||
|
||
case 'email':
|
||
// Email проверяем только если поле не пустое
|
||
if (value.trim().length > 0) {
|
||
isValid = validators.email(value);
|
||
}
|
||
break;
|
||
|
||
case 'pattern':
|
||
// Pattern проверяем только если поле не пустое
|
||
if (value.trim().length > 0) {
|
||
isValid = validators.pattern(value, rule.pattern);
|
||
} else if (rule.required === false) {
|
||
// Если поле опциональное и пустое - валидно
|
||
isValid = true;
|
||
} else {
|
||
// Если поле обязательное, но пустое - будет проверено правилом required
|
||
isValid = true;
|
||
}
|
||
break;
|
||
|
||
case 'minLength':
|
||
// MinLength проверяем только если поле не пустое
|
||
if (value.trim().length > 0) {
|
||
isValid = validators.minLength(value, rule.value);
|
||
}
|
||
break;
|
||
|
||
case 'maxLength':
|
||
isValid = validators.maxLength(value, rule.value);
|
||
break;
|
||
|
||
case 'match':
|
||
isValid = validators.match(value, formData, rule.field);
|
||
break;
|
||
|
||
case 'anyContact':
|
||
isValid = validators.anyContact(value, formData, fieldName, rule.fields);
|
||
break;
|
||
|
||
default:
|
||
// Кастомная функция валидации
|
||
if (typeof rule.validate === 'function') {
|
||
isValid = rule.validate(value, formData, input);
|
||
}
|
||
}
|
||
|
||
if (!isValid) {
|
||
return { valid: false, message: rule.message };
|
||
}
|
||
}
|
||
|
||
return { valid: true, message: '' };
|
||
}
|
||
|
||
/**
|
||
* Отображает feedback для поля
|
||
* @param {HTMLInputElement|HTMLSelectElement} input - Элемент поля
|
||
* @param {boolean} valid - Валидно ли поле
|
||
* @param {string} message - Сообщение об ошибке
|
||
*/
|
||
function showFieldFeedback(input, valid, message) {
|
||
// Очищаем HTML5 custom validity чтобы не конфликтовать
|
||
input.setCustomValidity('');
|
||
|
||
// Удаляем старые классы
|
||
input.classList.remove('is-valid', 'is-invalid');
|
||
|
||
// Ищем существующий feedback или создаём новый
|
||
let feedbackEl = input.parentElement.querySelector('.invalid-feedback');
|
||
|
||
if (!feedbackEl) {
|
||
feedbackEl = document.createElement('div');
|
||
feedbackEl.className = 'invalid-feedback';
|
||
input.parentElement.appendChild(feedbackEl);
|
||
}
|
||
|
||
if (valid) {
|
||
input.classList.add('is-valid');
|
||
feedbackEl.textContent = '';
|
||
feedbackEl.style.display = 'none';
|
||
} else {
|
||
input.classList.add('is-invalid');
|
||
feedbackEl.textContent = message;
|
||
feedbackEl.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Очищает feedback для поля
|
||
* @param {HTMLInputElement|HTMLSelectElement} input - Элемент поля
|
||
*/
|
||
function clearFieldFeedback(input) {
|
||
input.classList.remove('is-valid', 'is-invalid');
|
||
const feedbackEl = input.parentElement.querySelector('.invalid-feedback');
|
||
if (feedbackEl) {
|
||
feedbackEl.textContent = '';
|
||
feedbackEl.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Собирает данные формы в объект
|
||
* @param {HTMLFormElement} form - Элемент формы
|
||
* @returns {Object} Данные формы
|
||
*/
|
||
function getFormData(form) {
|
||
const formData = {};
|
||
const elements = form.elements;
|
||
|
||
for (let i = 0; i < elements.length; i++) {
|
||
const element = elements[i];
|
||
if (element.name) {
|
||
if (element.type === 'radio') {
|
||
if (element.checked) {
|
||
formData[element.name] = element.value;
|
||
}
|
||
} else if (element.type === 'checkbox') {
|
||
formData[element.name] = element.checked;
|
||
} else {
|
||
formData[element.name] = element.value;
|
||
}
|
||
}
|
||
}
|
||
|
||
return formData;
|
||
}
|
||
|
||
/**
|
||
* Инициализирует live validation для формы
|
||
* @param {Object} options - Настройки
|
||
* @param {string} options.formSelector - CSS селектор формы
|
||
* @param {Object} options.rules - Правила валидации для полей
|
||
* @param {number} [options.debounce=300] - Задержка перед валидацией (мс)
|
||
* @param {boolean} [options.validateOnBlur=true] - Валидировать при потере фокуса
|
||
* @param {boolean} [options.validateOnInput=true] - Валидировать при вводе
|
||
* @param {boolean} [options.validateOnSubmit=true] - Валидировать при отправке
|
||
* @returns {Object} API для управления валидацией
|
||
*/
|
||
export function initLiveValidation(options = {}) {
|
||
const {
|
||
formSelector,
|
||
rules = {},
|
||
debounce = 300,
|
||
validateOnBlur = true,
|
||
validateOnInput = true,
|
||
validateOnSubmit = true
|
||
} = options;
|
||
|
||
const form = document.querySelector(formSelector);
|
||
if (!form) {
|
||
console.error(`Form not found: ${formSelector}`);
|
||
return null;
|
||
}
|
||
|
||
const validationTimers = {};
|
||
const touchedFields = new Set();
|
||
|
||
/**
|
||
* Валидирует конкретное поле
|
||
* @param {HTMLInputElement|HTMLSelectElement} input
|
||
*/
|
||
const validateInput = (input) => {
|
||
const fieldName = input.name;
|
||
const fieldRules = rules[fieldName];
|
||
|
||
if (!fieldRules) return;
|
||
|
||
const formData = getFormData(form);
|
||
const result = validateField(input, fieldRules, formData);
|
||
|
||
showFieldFeedback(input, result.valid, result.message);
|
||
};
|
||
|
||
/**
|
||
* Валидирует поле с задержкой (debounce)
|
||
* @param {HTMLInputElement|HTMLSelectElement} input
|
||
*/
|
||
const validateInputDebounced = (input) => {
|
||
const fieldName = input.name;
|
||
|
||
// Очищаем предыдущий таймер
|
||
if (validationTimers[fieldName]) {
|
||
clearTimeout(validationTimers[fieldName]);
|
||
}
|
||
|
||
// Устанавливаем новый таймер
|
||
validationTimers[fieldName] = setTimeout(() => {
|
||
validateInput(input);
|
||
}, debounce);
|
||
};
|
||
|
||
/**
|
||
* Обработчик ввода в поле
|
||
*/
|
||
const handleInput = (e) => {
|
||
const input = e.target;
|
||
const fieldName = input.name;
|
||
|
||
// Санитизация телефонных номеров
|
||
if (input.type === 'tel') {
|
||
const previousValue = input.value;
|
||
const sanitized = sanitizePhone(previousValue);
|
||
|
||
if (previousValue !== sanitized) {
|
||
const cursorPosition = input.selectionStart || sanitized.length;
|
||
const diff = previousValue.length - sanitized.length;
|
||
|
||
input.value = sanitized;
|
||
|
||
try {
|
||
const newPosition = Math.max(0, cursorPosition - diff);
|
||
input.setSelectionRange(newPosition, newPosition);
|
||
} catch (err) {
|
||
// Игнорируем ошибки setSelectionRange
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!validateOnInput) return;
|
||
|
||
// Валидируем только если поле уже было "тронуто"
|
||
if (touchedFields.has(fieldName)) {
|
||
validateInputDebounced(input);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Обработчик потери фокуса
|
||
*/
|
||
const handleBlur = (e) => {
|
||
const input = e.target;
|
||
const fieldName = input.name;
|
||
|
||
// Помечаем поле как "тронутое"
|
||
touchedFields.add(fieldName);
|
||
|
||
if (validateOnBlur && rules[fieldName]) {
|
||
validateInput(input);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Обработчик отправки формы
|
||
*/
|
||
const handleSubmit = (e) => {
|
||
if (!validateOnSubmit) return;
|
||
|
||
let isFormValid = true;
|
||
const formData = getFormData(form);
|
||
|
||
// Валидируем все поля с правилами
|
||
for (const fieldName in rules) {
|
||
const input = form.elements[fieldName];
|
||
if (!input) continue;
|
||
|
||
const result = validateField(input, rules[fieldName], formData);
|
||
showFieldFeedback(input, result.valid, result.message);
|
||
|
||
if (!result.valid) {
|
||
isFormValid = false;
|
||
}
|
||
}
|
||
|
||
// Прерываем отправку если есть ошибки
|
||
if (!isFormValid) {
|
||
e.preventDefault();
|
||
|
||
// Фокусируем первое невалидное поле
|
||
const firstInvalid = form.querySelector('.is-invalid');
|
||
if (firstInvalid) {
|
||
firstInvalid.focus();
|
||
}
|
||
}
|
||
};
|
||
|
||
// Навешиваем обработчики
|
||
form.addEventListener('input', handleInput);
|
||
form.addEventListener('blur', handleBlur, true); // true для capture phase
|
||
form.addEventListener('submit', handleSubmit);
|
||
|
||
/**
|
||
* API для управления валидацией
|
||
*/
|
||
return {
|
||
/**
|
||
* Валидирует конкретное поле вручную
|
||
*/
|
||
validateField: (fieldName) => {
|
||
const input = form.elements[fieldName];
|
||
if (input) {
|
||
touchedFields.add(fieldName);
|
||
validateInput(input);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Валидирует всю форму
|
||
*/
|
||
validateForm: () => {
|
||
let isValid = true;
|
||
const formData = getFormData(form);
|
||
|
||
for (const fieldName in rules) {
|
||
const input = form.elements[fieldName];
|
||
if (!input) continue;
|
||
|
||
const result = validateField(input, rules[fieldName], formData);
|
||
showFieldFeedback(input, result.valid, result.message);
|
||
|
||
if (!result.valid) {
|
||
isValid = false;
|
||
}
|
||
}
|
||
|
||
return isValid;
|
||
},
|
||
|
||
/**
|
||
* Очищает валидацию для всех полей
|
||
*/
|
||
clearValidation: () => {
|
||
for (const fieldName in rules) {
|
||
const input = form.elements[fieldName];
|
||
if (input) {
|
||
clearFieldFeedback(input);
|
||
}
|
||
}
|
||
touchedFields.clear();
|
||
},
|
||
|
||
/**
|
||
* Удаляет обработчики
|
||
*/
|
||
destroy: () => {
|
||
form.removeEventListener('input', handleInput);
|
||
form.removeEventListener('blur', handleBlur, true);
|
||
form.removeEventListener('submit', handleSubmit);
|
||
}
|
||
};
|
||
}
|
||
|
||
// Экспорт для совместимости с неModular кодом
|
||
if (typeof window !== 'undefined') {
|
||
window.initLiveValidation = initLiveValidation;
|
||
}
|