Corp/backend/static/js/components/liveValidationHandler.js

476 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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;
}