Formularz kontaktowy

W poniższym tekście stworzymy dynamicznie sprawdzany i wysyłany formularz kontaktowy kontaktowy.

Gotowy formularz
W poniższym kodzie będziemy korzystać z funkcjonalności ES6 takich jak funkcja strzałkowa czy spread operator. Jeżeli chcesz wspierać starsze IE, będziesz musiał przerobić niektóre fragmenty kodu na starsze rozwiązania. Ewentualnie zastosuj transpilację za pomocą Babela

HTML

Zaczynamy od prostego kodu html i jego omówienia:

<form class="form" id="contactForm" method="post" action="/send-scripts.php">
    <div class="form-row">
        <label for="field-name">Name*</label>
        <input type="text" name="name" required id="field-name" data-error="Wypełnij to pole" pattern="[a-zA-ZąĄććę곣ńŃóÓśŚżŻŹŹ ]+">
    </div>
    <div class="form-row">
        <label for="field-email">Email*</label>
        <input type="email" name="email" required id="field-email" data-error="Wpisz poprawny email" pattern="[^@\s]+@[^@\s]+\.[^@\s]+">
    </div>
    <div class="form-row">
        <label for="field-message">Message*</label>
        <textarea name="message" required data-error="Musisz wypełnić pole" id="field-message" pattern=".+"></textarea>
    </div>
    <div class="form-row">
        <label class="checkbox-cnt">
            <input type="checkbox" data-error="Musisz wypełnić pole" name="regulation">
            <i class="state" aria-hidden="true"></i>
            <span>Lorem ipsum dolor sit amet, consectetur adipiscing elit. In libero arcu, vulputate sit amet mattis sit amet, ultrices in erat. Aenean suscipit arcu ac lorem lacinia ut scelerisque turpis commodo.</span>
        </label>
    </div>
    <div class="form-row">
        <button type="submit" class="submit-btn">
            Wyślij
        </button>
    </div>
</form>

Jest to formularz z 2 polami tekstowymi, jednym textarea, inputem typu checbox i buttonem do wysyłania.

Każdy formularz powinien mieć klasę dla stylowania, atrybut action, który wskazuje, gdzie ten formularz zostanie wysłany (w jakie miejsce), oraz atrybut method, który wskazuje jakiego typy będzie to wysyłka.

Każde pole w formularzu ma swój label. Nawet jeżeli takie labele nie będą widoczne w layoucie, powinni istnieć w kodzie html ze względów dostępności. Placeholder nie będzie tutaj rozwiązaniem. Label taki wskazuje za pomocą atrybutu for na id danego pola.
Pola te mają też podstawowe atrybuty takie jak typ, required (pole wymagane) czy pattern, które określa wzór treści jaką wymaga dane pole. Zauważ, że dla pola typu email też podałem ten atrybut, mimo, że domyślnie to pole też jest sprawdzane przez przeglądarkę. Domyślna walidacja przepuszcza maile w stylu example@, więc nie zawsze będzie nam pasować.

Dla checkboxa zastosowałem kod który dawno temu pokazałem tutaj: http://domanart.pl/wtf-forms/.

Jeżeli pola posiadają atrybut required (czyli są wymagane), wtedy przy wysyłaniu formularza pojawią nam się domyślne dymki walidacji. Aby podczas wysyłania formularza nie pojawiały się dymki walidacji którą udostępnia przeglądarka - nie jesteśmy w stanie zmienić.

Dymki walidacji

To dobrze, że one są. My jednak chcemy zrobić własną walidację. Aby wyłączyć takie dymki tworząc naszą walidację musimy do formularza dodać atrybut novalidate.

Każde pole ma także dodatkowy atrybut data-error, który zawiera tekst, który będzie wyświetlany w razie błędu.

CSS

Do naszego formularza dodajemy proste stylowanie:


* {
    box-sizing: border-box;
}

.form {
    margin: 3rem auto;
    font-family: sans-serif;
    max-width: 40rem;
}
.form .form-row {
    margin-bottom: 1rem;
}
.form .form-row:last-child {
    margin-bottom: 0;
}
.form input[type=text],
.form input[type=email],
.form textarea,
.form .checkbox-cnt .state {
    box-shadow:inset 0 1px 5px rgba(0,0,0,0.07);
}
.form input[type=text],
.form input[type=email],
.form textarea {
    border-radius: 0.2rem;
    font-family: sans-serif;
    padding: 0.8rem;
    border: 1px solid #aaa;
    display: block;
    width: 100%;
    color: #666;
}
.form input[type=text]:focus,
.form input[type=email]:focus,
.form textarea:focus {
    border-color: #9DC9F5;
    box-shadow: inset 0 0 0 1px #9DC9F5, inset 0 1px 5px rgba(0,0,0,0.07);
    outline: none;
}
.form textarea {
    height: 10rem;
}
.form label {
    font-weight: bold;
    display: block;
    font-size: 0.9rem;
    margin-bottom: 0.5rem;
}
.form .submit-btn {
    font-family: sans-serif;
    padding: 1rem 2rem;
    background: #F15C5C;
    border: 0;
    border-radius: 0.2rem;
    color: #fff;
    font-size: 1.1rem;
    font-weight: bold;
    transition: 0.3s background-color;
}
@media screen and (max-width:500px) {
    .form .submit-btn {
        display: block;
        width: 100%;
    }
}


/* ---------------------------------------------------- */
/* walidacja formularza */
/* ---------------------------------------------------- */
.form input[type=text].error,
.form input[type=email].error,
.form textarea.error,
.form .checkbox-cnt input.error ~ .state {
    border-color:##E01546;
}
.field-error {
    color:##E01546;
    padding:0.5rem 0;
    font-size:0.8rem;
}


/* ---------------------------------------------------- */
/* checkbox */
/* ---------------------------------------------------- */
.form .submit-btn:hover {
    background: #7EC927;
}
.form .checkbox-cnt {
    padding-left: 3rem;
    position: relative;
    font-weight: normal;
    font-size: 0.85rem;
    line-height: 1.1rem;
    color: #444;
    cursor: pointer;
}
.form .checkbox-cnt .state {
    width: 2rem;
    height: 2rem;
    display: block;
    position: absolute;
    left: 0;
    top: 0;
    border: 1px solid #aaa;
    border-radius: 0.2rem;
}
.form .checkbox-cnt .state:before {
    width: 1rem;
    height: 1rem;
    border-radius: 0.2rem;
    background: #70B81B;
    display: block;
    position: absolute;
    left: 50%;
    top: 50%;
    content: '';
    transform: translate(-50%, -50%) scale(1);
    opacity: 0;
}
.form .checkbox-cnt input:checked ~ .state:before {
    animation: checkboxShowAnim 0.5s 1;
    opacity: 1;
}
.form .checkbox-cnt input {
    position: absolute;
    top:0;
    left:0;
    width: 2rem;
    height: 2rem;
    z-index: 2;
    cursor: pointer;
    padding: 0;
    margin: 0;
    opacity: 0;
}
@keyframes checkboxShowAnim {
    0%  { border-radius:50%; transform:translate(-50%, -50%) scale(0.2); }
    50% { transform:translate(-50%, -50%) scale(1.2); }
    100% { transform:translate(-50%, -50%) scale(1); }
}

Połowa stylowania dotyczy się checkboxa, przedstawiłem ją we wspomnianym artykule

Formularz wygląd

Logika po stronie przeglądarki

Zanim przejdziemy do kodowania rozpiszmy logikę działania formularza:

  1. 1 etap walidacji - pola naszego formularza powinny mieć dynamiczną walidację, która będzie reagować na wpisywany tekst. Nie powinna ona być nachalna, czyli raczej nie będziemy użytkownika bombardować dodatkowymi opisami, a co najwyżej będziemy zmieniać wygląd pola
  2. 2 etap walidacji - przy wysyłaniu formularza sprawdzimy wszystkie pola i w razie czego wyświetlimy dodatkowe opisy pod polami co należy wpisać.
  3. Jeżeli wszystkie pola są poprawne, wtedy wysyłamy formularzy, pokazujemy znacznik wczytywania (loading) i wyłączamy przycisk submit, tak by użytkownik nie mógł "mashować" nachalnej wysyłki
  4. 3 etap walidacji - jeżeli przesłane na serwer dane z formularza są błędne, serwer powinien zwrócić informacje które pola są błędne, a my powinniśmy wyświetlić odpowiednie informacje
  5. Jeżeli formularz został wysłany poprawnie (wszystkie dane są ok i nie było błędu wysyłki) wtedy powinna pojawić się informacja o powodzeniu wysłania emaila

Po rozpisaniu tych punktów przejdźmy do napisania prostego skryptu walidacji.

Pierwszą czynnością jest pobranie formularza i jego pól które będziemy spradzać:


const form = document.querySelector('#contactForm');
const inputs = form.querySelectorAll('input[required], textarea[required], select[required]');

//wyłączamy domyślną walidację
form.setAttribute('novalidate', true);

Kolejnym krokiem jest podpięcie pod nie eventu input, który reaguje na wpisywany tekst.

Na pobranej za pomocą querySelectorAll kolekcji nie można normalnie robić forEach, dlatego stosujemy tutaj trik z zamianą kolekcji na tablicę poprzez rozbicie za pomocą spread operatora. Dodatkowo w pętli stosujemy funkcję strzałkową, ponieważ w tym momencie nie potrzebujemy mieć dostępu do this:


const form = document.querySelector('#contactForm');
const inputs = form.querySelectorAll('input[required], textarea[required], select[required]');

//wyłączamy domyślną walidację
form.setAttribute('novalidate', true);

//zamieniamy inputs na tablicę i robimy po niej pętlę
[...inputs].forEach(elem => {
    elem.addEventListener('input', function() {
        if (!this.checkValidity()) {
            this.classList.add('error');
        } else {
            this.classList.remove('error');
            hideFieldError(this);
        }
    });
});

Do sprawdzenia poprawności skorzystaliśmy z html5 js validation, a dokładniej z metody checkValidity(), która automatycznie sprawdza wartość pola - albo korzystając z type pola, albo z atrybutu pattern. Jeżeli więc mamy pole typu email, domyślnie walidacja przeglądarki przyjmie swój własny algorytm by sprawdzić wpisaną w to pole wartość. Jeżeli takie pole dostanie dodatkowo atrybut pattern, wtedy zostanie on użyty do takiego sprawdzenia.

Ma to szczególne znaczenie, dla przykładu domyślna walidacja pola email, które ma tylko atrybut required (bez pattern) przepuszcza maile w postaci "lorem@lorem", co nie każdemu może odpowiadać, bo zazwyczaj mail powinien mieć postać z końcową domentą np. "lorem@lorem.pl". Dlatego w wielu przypadkach mimo nadania polu jakiegoś typu warto dodatkowo wstawić tam własny atrybut pattern - tak jak my to zrobiliśmy w naszym formularzu.

W naszym przypadku zamiast tego api moglibyśmy skorzystać z wyrażeń regularnych tak jak poniżej:

//zamiast
if (!input.checkValidity()) {
    ...
}

//możemy spokojnie zastosować klasyczną metodę z regexp
const pattern = input.getAttribute('pattern');
if (new RegExp(pattern).test(input.value) {
    ...
}

Wracamy do kodu. W powyższej pętli forEach korzystamy z metod hideFieldError(). Napiszmy ją oraz jej przeciwieństwo czyli showFieldError(), która przyda nam się później:


const form = document.querySelector('#contactForm');
const inputs = form.querySelectorAll('input[required], textarea[required], select[required]');

//wyłączamy domyślną walidację
form.setAttribute('novalidate', true);

const displayFieldError = function(elem) {
    const fieldRow = elem.closest('.form-row');
    const fieldError = fieldRow.querySelector('.field-error');
    //jeżeli komunikat z błędem pod polem nie istnieje...
    if (fieldError === null) {
        //pobieramy z pola tekst błędu
        //i tworzymy pole
        const errorText = elem.dataset.error;
        const divError = document.createElement('div');
        divError.classList.add('field-error');
        divError.innerText = errorText;
        fieldRow.appendChild(divError);
    }
};

const hideFieldError = function(elem) {
    const fieldRow = elem.closest('.form-row');
    const fieldError = fieldRow.querySelector('.field-error');
    //jeżeli pobrane pole istnieje - usuń je
    if (fieldError !== null) {
        fieldError.remove();
    }
};

[...inputs].forEach(elem => {
    ...
});

W powyższych metodach skorzystaliśmy z metody closest(). Nie jest ona w pełni wspierana, dlatego jeżeli będzie nam zależało na wsparciu IE, trzeba będzie zastosować polyfil, opisany w powyższym linku.

Dla pól typu checkbox musimy dodatkowo podpiąć zdarzenie click:


const form = document.querySelector('#contactForm');
const inputs = form.querySelectorAll('input[required], textarea[required], select[required]');

//wyłączamy domyślną walidację
form.setAttribute('novalidate', true);

const displayFieldError = function(elem) {
    ....
};

const hideFieldError = function(elem) {
    ...
};

[...inputs].forEach(elem => {
    elem.addEventListener('input', function() {
        if (!this.checkValidity()) {
            this.classList.add('error');
        } else {
            this.classList.remove('error');
            hideFieldError(this);
        }
    });

    if (elem.type === "checkbox") {
        elem.addEventListener('click', function() {
            const formRow = this.closest('.form-row');
            if (this.checked) {
                this.classList.remove('error');
                hideFieldError(this);
            } else {
                this.classList.add('error');
            }
        });
    }
});
Dynamiczna walidacja

Dodanie tekstów walidacji

Przy wysyłaniu formularza powinniśmy dokonać ponownej walidacji, tym razem jednak wyświetlimy teksty pod polami, które będą mówić użytkownikowi co zrobił nie tak. Stworzymy do tego metodę checkFiledsErrors(), która działać będzie podobnie do powyższych, tyle że nie dla pojedynczego pola, a dla wielu:


const form = document.querySelector('#contactForm');
const inputs = form.querySelectorAll('input[required], textarea[required], select[required]');

//wyłączamy domyślną walidację
form.setAttribute('novalidate', true);

const displayFieldError = function(elem) {
    ...
};

const hideFieldError = function(elem) {
    ...
};

[...inputs].forEach(elem => {
   ...
});

const checkFieldsErrors = function(elements) {
    //ustawiamy zmienną na true. Następnie robimy pętlę po wszystkich polach
    //jeżeli któreś z pól jest błędne, przełączamy zmienną na false.
    let fieldsAreValid = true;

    [...elements].forEach(elem => {
        if (elem.checkValidity()) {
            hideFieldError(elem);
            elem.classList.remove('error');
        } else {
            displayFieldError(elem);
            elem.classList.add('error');
            fieldsAreValid = false;
        }
    });

    return fieldsAreValid;
};

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

    //jeżeli wszystkie pola są poprawne...
    if (checkFieldsErrors(inputs)) {
        ...
    }
});
Walidacja przed wysyłką

Wysyłka formularza

W tym momencie możemy już wysłać formularz a i nasza walidacja po stronie przeglądarki działa.

Żeby wysłać dane, musimy pobrać wszystkie dane z formularza. Wystarczy zrobić pętlę po elementach formularza które nie są :disabled (bo takie normalnie nie są wysyłane) i wrzucić ich wartości do wspólnego obiektu dataToSend typu formData:


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

    //jeżeli wszystkie pola są poprawne...
    if (checkFieldsErrors(inputs)) {
        //generujemy dane jako obiekt dataToSend
        //domyślnie elementy disabled nie są wysyłane!
        const elements = form.querySelectorAll('input:not(:disabled), textarea:not(:disabled), select:not(:disabled)');

        const dataToSend = new FormData();
        [...elements].forEach(el => dataToSend.append(el.name, el.value));

        ...
    }
});

Mamy zebrane dane. Pozostaje je wysłać.

Przed samym wysłaniem danych wyłączmy przycisk submit poprzez dodanie do niego atrybutu disabled. Dzięki temu zniecierpliwiony użytkownik nie będzie mógł klikać w przycisk wysyłania aż do czasu zakończenia poprzedniej wysyłki.

Dodatkowo powinniśmy pokazać jakiś wskaźnik wczytywania. Możemy to zrobić jakąś graficzką loadingu, ale lepiej zastosować jakąś prostą animację:


/* ---------------------------------------------------- */
/* loading */
/* ---------------------------------------------------- */
.elem-is-busy {
    position: relative;
    pointer-events: none;
    opacity:0.5;
}

.elem-is-busy::after {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    border: 2px solid rgba(0, 0, 0, 0.2);
    border-right-color: rgba(0,0,0,0.7);
    transform: translate(-50%, -50%) rotate(0deg);
    content:'';
    animation: rotateSingleLoading 0.3s infinite linear;
    z-index: 10;
}

@keyframes rotateSingleLoading {
    from {
        transform: translate(-50%, -50%) rotate(0deg);
    }
    to {
        transform: translate(-50%, -50%) rotate(360deg);
    }
}

document.querySelector('#loadingTest').addEventListener('click', function() {
    this.classList.add('element-is-busy');
    this.disabled = true;
});

Zastosujmy ją w naszym skrypcie przy dynamicznym wysyłaniu danych:


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

    if (checkFieldsErrors(inputs)) {
        const elements = form.querySelectorAll('input:not(:disabled), textarea:not(:disabled), select:not(:disabled)');

        const dataToSend = new FormData();
        [...elements].forEach(el => dataToSend.append(el.name, el.value));

        const submit = form.querySelector('[type="submit"]');
        submit.disabled = true;
        submit.classList.add('element-is-busy');

        //zaraz wyślemy dane
        //...
    }
})

Do samej wysyłki zastosujemy fetch. Dla adresu i metody jaką wyślemy musimy pobrać atrybuty action i method z formularza. Wcześniej pobrane dane z formularza wysyłamy jako POST, dlatego zamieniamy je za pomocą formData na właściwy zapis:


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

    if (checkFieldsErrors(inputs)) {
        const elements = form.querySelectorAll('input:not(:disabled), textarea:not(:disabled), select:not(:disabled)');

        const dataToSend = new FormData();
        [...elements].forEach(el => dataToSend.append(el.name, el.value));

        const submit = form.querySelector('[type="submit"]');
        submit.disabled = true;
        submit.classList.add('element-is-busy');

        const url = form.getAttribute('action');
        const method = form.getAttribute('method');

        fetch(url, {
            method: method.toUpperCase(),
            body: dataToSend
        })
        .then(ret => ret.json())
        .then(ret => {
            submit.disabled = false;
            submit.classList.remove('element-is-busy');
        }).catch(_ => {
            submit.disabled = false;
            submit.classList.remove('element-is-busy');
        });
    }
})

Zauważ, że przy użyciu funkcji strzałkowej dla catch atrybut nazwałem jako znak podłogi. Jest to spotykana praktyka, która oznacza, że taki atrybut nie będzie używany i nas nie interesuje.

Po stronie serwera

Formularz wysłaliśmy na serwer. Czas zająć się napisaniem skryptu serwerowego. Do naszych celów użyjemy PHP, ale podobne działanie można utworzyć w każdej innej technologii serwerowej:


<?php

$mailToSend = 'twoj-mail@lorem.pl';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $name = $_POST['name'];
    $email = $_POST['email'];
    $message = $_POST['email'];
    $regulation = $_POST['regulation']; //walidacja regulaminu - nie zawsze potrzebna
    $errors = Array();
	$return = Array();

    if (empty($name)) {
        array_push($errors, 'name');
    }
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        array_push($errors, 'email');
    }
    if (empty($message)) {
        array_push($errors, 'message');
    }
    if (empty($regulation)) { //walidacja regulaminu - nie zawsze potrzebna
        array_push($errors, 'regulation');
    }
    if (count($errors) > 0) {
        $return['errors'] = $errors;
    } else {
        $headers  = 'MIME-Version: 1.0' . "\r\n";
        $headers .= 'Content-type: text/html; charset=UTF-8'. "\r\n";
        $headers .= 'From: '.$email."\r\n";
        $headers .= 'Reply-to: '.$email;
        $message  = "
            <html>
                <head>
                    <meta charset=\"utf-8\">
                </head>
                <style type=\"text/css\">
                    body {font-family:sans-serif; padding:20px;}
                    div {margin-bottom:10px;}
                    .msg-title {margin-top:30px;}
                </style>
                <body>
                    <div>Imię: $name</div>
                    <div>Email: <a href=\"mailto:$email\">$email</a></div>
                    <div>Wiadomość:</div>
                    <div>$message</div>
                </body>
            </html>";

        if (mail($mailToSend, 'Wiadomość ze strony - ' . date("d-m-Y"), $message, $headers)) {
            $return['status'] = 'ok';
        } else {
            $return['status'] = 'error';
        }
    }

    header('Content-Type: application/json');
    echo json_encode($return);
}

Jest to prosty skrypt, który kolejno sprawdza przesłane pola. Jeżeli któreś z pól jest błędne, wtedy wrzuca do tablicy $errors pod klucz będący nazwą tego pola tekst z komunikatem błędu.

W powyższym skrypcie sprawdzamy pole $_POST['regulation'] które wysyłamy z formularza. W wielu przypadkach takie pole będzie nam zbędne. Jeżeli będziesz chciał użyć powyższego kodu, wtedy usuń dwa zaznaczone w tym listingu miejsca.

W rezultacie powyższego skryptu gdy wystąpią błędy, do skryptu po stronie przeglądarki trafi odpowiedź w formacie json:


{
    "errors" : [
        "name",
        "email",
        "message",
        ...
    ]
}

Gdy dostaniemy z serwera powyższą odpowiedź, zrobimy pętlę po tablicy errors i dla każdego pola o danej nazwie zastosujemy wcześniej napisaną funkcję fieldsHasErrors().

Gdy jednak wszystkie pola będą wypełnione poprawnie, powyższy skrypt tworzy prostą wiadomość email, wysyła ją za pomocą funkcji mail() i wysyła do naszego skryptu js prostą odpowiedź:


{
    "status" : "ok"
}
Jeżeli by się okazało, że mail nie mógł być wysłany (np. błąd z serwerem), wtedy wróci do nas zwrotka z jsonem:

{
    "status" : "error"
}

Odpowiedź z serwera - błędne dane

Serwer dostał paczkę danych, obsłużył ją i zwraca nam odpowiedź. W pierwszej kolejności obsłużmy odpowiedź z wypisanymi błędnie wypełnionymi polami:


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

    if (checkFieldsErrors(inputs)) {
        ...

        fetch(url, {
            ...
        }).then(function(ret) {
            submit.disabled = false;
            submit.classList.remove('element-is-busy');

            if (ret.errors) {
                ret.errors.map(function(el) {
                    return '[name="'+el+'"]'
                });

                const badFields = form.querySelectorAll(ret.errors.join(','));
                checkFieldsErrors(badFields);
            } else {
                if (ret.status === 'ok') {
                    //wyświetlamy komunikat powodzenia, cieszymy sie
                }
                if (ret.status === 'error') {
                    //komunikat błędu, niepowodzenia
                }
            }
        }).catch(function(ret) {
            submit.disabled = false;
            submit.classList.remove('element-is-busy');
        });
    }
})

Gdy dostaniemy w odpowiedzi błędy w postaci zmiennej ret.errors, każdą wartość tej tablicy (które są tak naprawdę nazwami naszych pól) zmieniamy za pomocą map na teksty np. [name="email"] czy [name="message"]. Następnie taką tablicę łączymy za pomocą join(','), dzięki czemu uzyskujemy zapis:


const badFields = form.querySelectorAll(ret.errors.join(','));
//da nam w wyniku
const badFields = form.querySelectorAll('[name="name"], [name="email"], [name="message"]...')

Powyższy zapis przekazujemy do checkFieldsErrors() co powoduje odpalenie walidacji dla wszystkich błędnych pól.

Odpowiedź z serwera - błąd serwera

Pozostały nam rzeczy małe, czyli obsługa odpowiedzi, gdy status równy jest "ok" i "error".

Zacznijmy od błędów. Dla error możemy dodać do formularza tekst z komunikatem błędu wysyłki. Gdzie? A to zależy od naszych upodobać - może być np. po prawej stronie przycisku wyślij (a może jakiś toast):


form.addEventListener('submit', function(e) {
    e.preventDefault();
    ...

    if (checkFieldsErrors(inputs)) {
        ...

        fetch(url, {
            ...
        }).then(function(ret) {
            submit.disabled = false;
            submit.classList.remove('element-is-busy');

            if (ret.errors) {
                ret.errors.map(function(el) {
                    return '[name="'+el+'"]'
                });
                checkFieldsErrors(form.querySelectorAll(ret.errors.join(','));
            } else {
                if (ret.status === 'ok') {
                    //wyświetlamy komunikat powodzenia, cieszymy sie
                }
                if (ret.status === 'error') {
                    //jeżeli istnieje komunikat o błędzie wysyłki
                    //np. generowany przy poprzednim wysyłaniu formularza
                    //usuwamy go, by nie duplikować tych komunikatów
                    if (document.querySelector('.send-error')) {
                        document.querySelector('.send-error').remove();
                    }
                    const div = document.createElement('div');
                    div.classList.add('send-error');
                    div.innerText = 'Wysłanie wiadomości się nie powiodło';
                    submit.parentElement.appendChild(div);
                }
            }
        }).catch(function(ret) {
            submit.disabled = false;
            submit.classList.remove('element-is-busy');
        });
    }
})

Dodajmy też proste stylowanie dla tego komunikatu:


.form .send-error {
    display:inline-block;
    font-family: sans-serif;
    padding:1rem 2rem;
    color:red;
}

@media screen and (max-width:500px) {
    .form .send-error {
        text-align:center;
        display: block;
    }
}

Odpowiedź z serwera - wreszcie pozytywnie

Uff. Zajmijmy się teraz pozytywną odpowiedzią. Możemy tutaj oczywiście wstawić tekst tak samo jak w przypadku błędu serwera. Polecam jednak zastąpienie całego formularza html z informacją o pozytywnej wysyłce. Dzięki temu formularz będzie trochę bardziej odporny na różne ataki robotów:


fetch(url, {
    ...
}).then(function(ret) {
    submit.disabled = false;
    submit.classList.remove('element-is-busy');

    if (ret.errors) {
        ret.errors.map(function(el) {
            return '[name="'+el+'"]'
        });
        checkFieldsErrors(form.querySelectorAll(ret.errors.join(','));
    } else {
        if (ret.status === 'ok') {
            const div = document.createElement('div');
            div.classList.add('form-send-success');
            div.innerText = 'Wysłanie wiadomości się nie powiodło';

            form.parentElement.insertBefore(div, form);
            div.innerHTML = '<strong>Wiadomość została wysłana</strong><span>Dziękujemy za kontakt. Postaramy się odpowiedzieć jak najszybciej</span>';
            form.remove();
        }

        if (ret.status === 'error') {
            //jeżeli istnieje komunikat o błędzie wysyłki
            //np. generowany przy poprzednim wysyłaniu formularza
            //usuwamy go, by nie duplikować tych komunikatów
            if (document.querySelector('.send-error')) {
                document.querySelector('.send-error').remove();
            }
            const div = document.createElement('div');
            div.classList.add('send-error');
            div.innerText = 'Wysłanie wiadomości się nie powiodło';
            submit.parentElement.appendChild(div);
        }
    }
}).catch(function(ret) {
    submit.disabled = false;
    submit.classList.remove('element-is-busy');
});

Oraz dodajmy dla takiego komunikatu stylowanie:


.form-send-success {
    font-family: sans-serif;
    text-align:center;
    font-size:2rem;
    font-weight:bold;
    border:1px solid #eee;
    color:#333;
    padding:10rem 0;
    margin:3rem auto;
    max-width:40rem;
}

.form-send-success strong {
    display:block;
    margin-bottom:0.5rem;
}

.form-send-success span {
    font-size:1rem;
    color:#888;
    font-weight:normal;
    display: block;
}

Cały kod

Poniżej zamieszczam cały kod JS wraz z gotowym formularzem:


{
    const form = document.querySelector('#contactForm');
    const inputs = form.querySelectorAll('input[required], textarea[required], select[required]');

    form.setAttribute('novalidate', true);

    const displayFieldError = function(elem) {
        const fieldRow = elem.closest('.form-row');
        const fieldError = fieldRow.querySelector('.field-error');

        //jeżeli komunikat z błędem pod polem nie istnieje...
        if (fieldError === null) {
            //pobieramy z pola tekst błędu
            //i tworzymy pole
            const errorText = elem.dataset.error;
            const divError = document.createElement('div');
            divError.classList.add('field-error');
            divError.innerText = errorText;
            fieldRow.appendChild(divError);
        }
    };

    const hideFieldError = function(elem) {
        const fieldRow = elem.closest('.form-row');
        const fieldError = fieldRow.querySelector('.field-error');
        //jeżeli pobrane pole istnieje - usuń je
        if (fieldError !== null) {
            fieldError.remove();
        }
    };

    [...inputs].forEach(elem => {
        elem.addEventListener('input', function() {
            if (!this.checkValidity()) {
                this.classList.add('error');
            } else {
                this.classList.remove('error');
                hideFieldError(this);
            }
        });

        if (elem.type === "checkbox") {
            elem.addEventListener('click', function() {
                const formRow = this.closest('.form-row');
                if (this.checked) {
                    this.classList.remove('error');
                    hideFieldError(this);
                } else {
                    this.classList.add('error');
                }
            });
        }
    });

    const checkFieldsErrors = function(elements) {
        //ustawiamy zmienną na true. Następnie robimy pętlę po wszystkich polach
        //jeżeli któreś z pól jest błędne, przełączamy zmienną na false.
        let fieldsAreValid = true;

        [...elements].forEach(elem => {
            if (elem.checkValidity()) {
                hideFieldError(elem);
                elem.classList.remove('error');
            } else {
                displayFieldError(elem);
                elem.classList.add('error');
                fieldsAreValid = false;
            }
        });

        return fieldsAreValid;
    };

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

        //jeżeli wszystkie pola są poprawne...
        if (checkFieldsErrors(inputs)) {
            const elements = form.querySelectorAll('input:not(:disabled), textarea:not(:disabled), select:not(:disabled)');

            const dataToSend = new FormData();
            [...elements].forEach(el => dataToSend.append(el.name, el.value));

            const submit = form.querySelector('[type="submit"]');
            submit.disabled = true;
            submit.classList.add('element-is-busy');

            const url = form.getAttribute('action');
            const method = form.getAttribute('method');

            fetch(url, {
                method: method.toUpperCase(),
                body: dataToSend
            })
            .then(ret => ret.json())
            .then(ret => {
                submit.disabled = false;
                submit.classList.remove('element-is-busy');

                if (ret.errors) {
                    ret.errors.map(function(el) {
                        return '[name="'+el+'"]'
                    });

                    const badFields = form.querySelectorAll(ret.errors.join(','));
                    checkFieldsErrors(badFields);
                } else {
                    if (ret.status === 'ok') {
                        const div = document.createElement('div');
                        div.classList.add('form-send-success');
                        div.innerText = 'Wysłanie wiadomości się nie powiodło';

                        form.parentElement.insertBefore(div, form);
                        div.innerHTML = '<strong>Wiadomość została wysłana</strong><span>Dziękujemy za kontakt. Postaramy się odpowiedzieć jak najszybciej</span>';
                        form.remove();
                    }

                    if (ret.status === 'error') {
                        //jeżeli istnieje komunikat o błędzie wysyłki
                        //np. generowany przy poprzednim wysyłaniu formularza
                        //usuwamy go, by nie duplikować tych komunikatów
                        if (document.querySelector('.send-error')) {
                            document.querySelector('.send-error').remove();
                        }
                        const div = document.createElement('div');
                        div.classList.add('send-error');
                        div.innerText = 'Wysłanie wiadomości się nie powiodło';
                        submit.parentElement.appendChild(div);
                    }
                }
            }).catch(_ => {
                submit.disabled = false;
                submit.classList.remove('element-is-busy');
            });
        }
    });
}
Gotowy formularz

Oraz paczka z plikami.

Mini bonus - słodki antyspam

Powyżej stworzyliśmy prosty w pełni dynamiczny formularz kontaktowy. Problem z takimi formularzami jest taki, że bardzo lubią je roboty rozsyłające spam. Aby im przeszkodzić musimy jakoś zabezpieczyć nasze dzieło. Jedną ze sprytniejszych metod jest tak zwana honeypot. Polega ona na dodaniu do formularza dodatkowego pola, które ukrywamy za pomocą styli:


<span class="form-row honey-row">
    <label for="honey">Jeżeli jesteś człowiekiem, nie wypełniaj tego pola</label>
    <input type="text" name="honey">
</span>

.form .honey-row {
    display:none;
}

Większość robotów ignoruje style, ani nie rozumie języka pisanego, więc wypełnią to pole. Wystarczy teraz po stronie serwera sprawdzić czy pole to ma wartość. Jak ma, olewamy całą sprawę, ewentualnie pokrzepiając robota miłymi słowami wiadomości w formie sukcesu:


<?php
$mailToSend = 'twoj-email@lorem.pl';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $name = $_POST['name'];
    $email = $_POST['email'];
    $message = $_POST['email'];
    $antiSpam = $_POST['honey'];
    $regulation = $_POST['regulation'];
    $errors = Array();
	$return = Array();

    if (empty($antiSpam)) {

        ...

    } else {
        $return['status'] = 'ok';
    }

    header('Content-Type: application/json');
    echo json_encode($return);
}