Урок 11

Практика: работа с формами

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

function validateField(input) {
    const value = input.value.trim();
    const type = input.dataset.validate;

    let error = '';

    if (input.required && !value) {
        error = 'Поле обязательно';
    } else if (type === 'email' && !/^\S+@\S+\.\S+$/.test(value)) {
        error = 'Некорректный email';
    } else if (type === 'phone' && !/^\+?[\d\s()-]{10,}$/.test(value)) {
        error = 'Некорректный телефон';
    } else if (input.minLength > 0 && value.length < input.minLength) {
        error = `Минимум ${input.minLength} символов`;
    }

    input.classList.toggle('invalid', !!error);
    input.setCustomValidity(error);

    const errorEl = input.parentElement.querySelector('.error');
    if (errorEl) errorEl.textContent = error;

    return !error;
}

document.querySelectorAll('input[data-validate]').forEach((input) => {
    input.addEventListener('blur', () => validateField(input));
    input.addEventListener('input', () => {
        if (input.classList.contains('invalid')) validateField(input);
    });
});

Валидация формы

form.addEventListener('submit', (event) => {
    event.preventDefault();

    const inputs = form.querySelectorAll('[data-validate]');
    const valid = [...inputs].every(validateField);

    if (!valid) {
        form.querySelector('.invalid')?.focus();
        return;
    }

    // отправка
});

Отправка с состоянием

form.addEventListener('submit', async (event) => {
    event.preventDefault();

    const submit = form.querySelector('button[type="submit"]');
    submit.disabled = true;
    submit.textContent = 'Отправка...';

    try {
        const data = Object.fromEntries(new FormData(form));

        const res = await fetch('/api/submit', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(data),
        });

        if (!res.ok) {
            const error = await res.json();
            throw new Error(error.message);
        }

        form.reset();
        showSuccess('Отправлено');
    } catch (error) {
        showError(error.message);
    } finally {
        submit.disabled = false;
        submit.textContent = 'Отправить';
    }
});

Автосохранение

let saveTimer;

form.addEventListener('input', () => {
    clearTimeout(saveTimer);
    saveTimer = setTimeout(saveDraft, 1000);
});

async function saveDraft() {
    const data = Object.fromEntries(new FormData(form));
    localStorage.setItem('draft', JSON.stringify(data));
}

// При загрузке
const draft = localStorage.getItem('draft');
if (draft) {
    const data = JSON.parse(draft);
    for (const [name, value] of Object.entries(data)) {
        const input = form.elements[name];
        if (input) input.value = value;
    }
}

Счётчик символов

textarea.addEventListener('input', () => {
    const max = textarea.maxLength;
    const current = textarea.value.length;
    counter.textContent = `${current} / ${max}`;

    counter.classList.toggle('warning', current > max * 0.9);
});

Зависимые поля

country.addEventListener('change', async () => {
    city.innerHTML = '<option>Загрузка...</option>';

    const res = await fetch(`/api/cities?country=${country.value}`);
    const cities = await res.json();

    city.innerHTML = cities
        .map((c) => `<option value="${c.id}">${c.name}</option>`)
        .join('');
});

Динамические поля

addButton.addEventListener('click', () => {
    const row = document.createElement('div');
    row.className = 'field-row';
    row.innerHTML = `
        <input name="items[]" placeholder="Название">
        <button type="button" class="remove">×</button>
    `;
    container.append(row);
});

container.addEventListener('click', (event) => {
    if (event.target.matches('.remove')) {
        event.target.closest('.field-row').remove();
    }
});

Загрузка файлов

input.addEventListener('change', () => {
    const files = [...input.files];

    const preview = files.map((file) => ({
        name: file.name,
        size: (file.size / 1024).toFixed(1) + ' KB',
        url: URL.createObjectURL(file),
    }));

    renderPreview(preview);
});

Drag and drop файлов

dropZone.addEventListener('dragover', (event) => {
    event.preventDefault();
    dropZone.classList.add('dragover');
});

dropZone.addEventListener('dragleave', () => {
    dropZone.classList.remove('dragover');
});

dropZone.addEventListener('drop', (event) => {
    event.preventDefault();
    dropZone.classList.remove('dragover');

    const files = event.dataTransfer.files;
    handleFiles(files);
});

Прогресс загрузки

function upload(file) {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();

        xhr.upload.addEventListener('progress', (event) => {
            if (event.lengthComputable) {
                const percent = (event.loaded / event.total) * 100;
                progressBar.style.width = `${percent}%`;
            }
        });

        xhr.addEventListener('load', () => resolve(xhr.response));
        xhr.addEventListener('error', () => reject(new Error('Upload failed')));

        const data = new FormData();
        data.append('file', file);

        xhr.open('POST', '/api/upload');
        xhr.send(data);
    });
}

Очистка черновика

form.addEventListener('submit', () => {
    localStorage.removeItem('draft');
});

Итоги

  • Валидация на blur и input
  • Состояние кнопки при отправке
  • Автосохранение через debounce
  • Зависимые поля через change
  • Динамические поля с делегированием
  • Прогресс загрузки через XHR