Видеокурс «Практический JavaScript», часть вторая

16.03.2021

Теги: JavaScriptWeb-разработкаМодульПрактика

Во второй части будем дорабатывать второй проект. У нас есть «голая» верстка, но нет еще js-кода, который бы оживил страницу. Начнем с модальных окон — js-код возьмем из первой части, но доработаем его. Нужно, чтобы модальное окно, которое открывается через минуту, открывалось только в том случае, если не открыто другое модальное окно.

const modals = () => {

    const allModals = document.querySelectorAll('[data-modal]'),
          scrollWidth = calcScrollWidth();

    function bindModal(triggerSelector, modalSelector, closeSelector) {
        const trigger = document.querySelectorAll(triggerSelector),
              modal = document.querySelector(modalSelector),
              close = document.querySelector(closeSelector);

        // на странице может быть несколько триггеров для окрытия одного и того
        // же модального окна, мы навешиваем обработчик события сразу на все
        trigger.forEach(item => {
            item.addEventListener('click', (e) => {
                if (e.target) {
                    e.preventDefault();
                }
                // если есть открытое модальное окно — закрываем его
                allModals.forEach(item => {
                    item.style.display = 'none';
                    document.body.style.overflow = '';
                    document.body.style.marginRight = '0px';
                });
                modal.style.display = 'block';
                document.body.style.overflow = 'hidden';
                document.body.style.marginRight = `${scrollWidth}px`;
            });
        });

        // закрыть модальное окно при клике на крестик
        close.addEventListener('click', () => {
            modal.style.display = 'none';
            document.body.style.overflow = '';
            document.body.style.marginRight = '0px';
        });

        // закрыть окно при клике за пределами окна
        modal.addEventListener('click', (e) => {
            if (e.target === modal) {
                modal.style.display = 'none';
                document.body.style.overflow = '';
                document.body.style.marginRight = '0px';
            }
        });
    }

    // открыть модальное окно через delay секунд
    function showModalByTime(selector, delay) {
        setTimeout(function() {
            // если какое-то модальное окно уже откыто, то еще одно
            // окно уже не открываем — оно будет только мешать
            let display = false;
            allModals.forEach(item => {
                if (getComputedStyle(item).display !== 'none') {
                    display = true; // какое-то окно уже открыто
                }
            });
            // все окна закрыты, значит можем показать это окно
            if (!display) {
                document.querySelector(selector).style.display = 'block';
                document.body.style.overflow = 'hidden';
                document.body.style.marginRight = `${scrollWidth}px`;
            }
        }, delay);
    }

    // вычисляем ширину скролла страницы; создаем div-элемент с прокруткой и вычисляем
    // разницу между полной шириной этого элемента и шириной без учета полосы прокрутки
    function calcScrollWidth() {
        let div = document.createElement('div');

        div.style.width = '50px';
        div.style.height = '50px';
        div.style.overflowY = 'scroll';
        div.style.visibility = 'hidden';

        document.body.appendChild(div);
        let scrollWidth = div.offsetWidth - div.clientWidth;
        div.remove();

        return scrollWidth;
    }

    bindModal('.button-design', '.popup-design', '.popup-design .popup-close');
    bindModal('.button-consultation', '.popup-consultation', '.popup-consultation .popup-close');

    showModalByTime('.popup-consultation', 60000);
};

export default modals;

Модальное окно с подарком

На странице есть кнопка с иконкой подарка. При клике на кнопку должно открываться модальное окно, а сама кнопка пропадать. Дорабатываем наш модуль modals.js в директории modules:

const modals = () => {
    /* ... */
    function bindModal(triggerSelector, modalSelector, closeSelector, destroyTrigger = false) {
        /* ... */
        trigger.forEach(item => {
            item.addEventListener('click', (e) => {
                if (e.target) {
                    e.preventDefault();
                }
                // NEW для модального окна подарка удаляем триггер
                if (destroyTrigger) {
                    item.remove();
                }
                allModals.forEach(item => {
                    item.style.display = 'none';
                    document.body.style.overflow = '';
                    document.body.style.marginRight = '0px';
                });
                modal.style.display = 'block';
                document.body.style.overflow = 'hidden';
                document.body.style.marginRight = `${scrollWidth}px`;
            });
        });

        close.addEventListener('click', () => {
            /* ... */
        });

        modal.addEventListener('click', (e) => {
            /* ... */
        });
    }

    function showModalByTime(selector, delay) {
        /* ... */
    }

    function calcScrollWidth() {
        /* ... */
    }

    bindModal('.button-design', '.popup-design', '.popup-design .popup-close');
    bindModal('.button-consultation', '.popup-consultation', '.popup-consultation .popup-close');
    // NEW модальное окно с подарком
    bindModal('.fixed-gift', '.popup-gift', '.popup-gift .popup-close', true);

    showModalByTime('.popup-consultation', 60000);
};

export default modals;

Модальное окно при scroll

Если пользователь долистал страницу до конца, но не нажал ни одну кнопку — должно появляться модальное окно подарка, а сама кнопка подарка должна исчезнуть.

const modals = () => {

    // NEW при окрытии пользователем модального окна принимает значение true
    let isOpenModal = false;
    const allModals = document.querySelectorAll('[data-modal]'),
          scrollWidth = calcScrollWidth();

    function bindModal(triggerSelector, modalSelector, closeSelector, destroyTrigger = false) {
        const trigger = document.querySelectorAll(triggerSelector),
              modal = document.querySelector(modalSelector),
              close = document.querySelector(closeSelector);

        // на странице может быть несколько триггеров для окрытия одного и того
        // же модального окна, мы навешиваем обработчик события сразу на все
        trigger.forEach(item => {
            item.addEventListener('click', (e) => {
                if (e.target) {
                    e.preventDefault();
                }
                // NEW пользователь сам открыл модальное окно
                isOpenModal = true;
                // для модального окна подарка удаляем триггер
                if (destroyTrigger) {
                    item.remove();
                }
                // если есть открытое модальное окно — закрываем его
                allModals.forEach(item => {
                    item.style.display = 'none';
                    document.body.style.overflow = '';
                    document.body.style.marginRight = '0px';
                });
                modal.style.display = 'block';
                document.body.style.overflow = 'hidden';
                document.body.style.marginRight = `${scrollWidth}px`;
            });
        });

        close.addEventListener('click', () => {
            /* ... */
        });

        modal.addEventListener('click', (e) => {
            /* ... */
        });
    }

    function showModalByTime(selector, delay) {
        /* ... */
    }

    function calcScrollWidth() {
        /* ... */
    }

    // открывает модальное окно, если страница прокручена вниз до
    // конца и пользователь не открыл ни одного модального окна
    function openByScroll(selector) {
        window.addEventListener('scroll', () => {
            // полная высота html-документа, включая скрытую за границами окна браузера
            const documentHeight = document.documentElement.scrollHeight;
            // сколько уже прокручено от начала документа + ширина видимого окна браузера
            const currentHeight = window.pageYOffset + document.documentElement.clientHeight;
            // страница уже прокручена до конца или еще нет?
            const isScrollEnd = Math.abs(documentHeight - currentHeight) < 10;
            // пользователь не открывал модальных окон и страница прокручена до конца
            if (!isOpenModal && isScrollEnd) {
                document.querySelector(selector).click();
            }
        });
    }

    bindModal('.button-design', '.popup-design', '.popup-design .popup-close');
    bindModal('.button-consultation', '.popup-consultation', '.popup-consultation .popup-close');
    bindModal('.fixed-gift', '.popup-gift', '.popup-gift .popup-close', true);
    // NEW открыть модальное окно при прокрутке до конца страницы
    openByScroll('.fixed-gift');

    showModalByTime('.popup-consultation', 60000);
};

export default modals;

Здесь у нас новая функция openByScroll(), которая отслеживает прокрутку страницы и в нужный момент показывает модальное окно. Кроме того, добавим анимацию при открытии любого модального окна. Поскольку к странице уже подключена библиотека animate.css — сделать это очень просто.

const modals = () => {
    // при окрытии пользователем модального окна принимает значение true
    let isOpenModal = false;
    const allModals = document.querySelectorAll('[data-modal]'),
    /* ... */
    // NEW все модальные окна на странице будут открываться с анимацией
    allModals.forEach(item => {
        item.classList.add('animated', 'fadeIn');
    });
    
    function bindModal(triggerSelector, modalSelector, closeSelector, destroyTrigger = false) {
        /* ... */
    }
    /* ... */
};

export default modals;

Добавляем слайдеры

На странице уже есть заготовки двух слайдеров, но пока они не работают. Первый слайдер вверху, он совсем простой — просто две картинки сменяют друг друга. Второй слайдер более классический — несколько сложных слайдов и стрелки влево и вправо для управления. Первый слайдер прокручивается вертикально, а второй — горизонтально.

<div class=col-md-7>
    <!-- первый слайдер -->
    <div class="main-slider">
        <div class="main-slider-item">
            <img src="assets/img/main-1.png" alt="">
        </div>
        <div class="main-slider-item">
            <img src="assets/img/main-2.png" alt="">
        </div>
    </div>
</div>
<div class="feedback">
    <div class="container">
        <!-- второй слайдер -->
        <div class="feedback-slider" style="position:relative; height: 400px;">
            <button class="main-slider-btn main-prev-btn">
                <img src="assets/img/left-arr.png" alt="left">
            </button>
            <button class="main-slider-btn main-next-btn">
                <img src="assets/img/right-arr.png" alt="right">
            </button>
            <div class="feedback-slider-item">
                <div class="col-sm-5">
                    <div class="feedback-block">
                        <img src="assets/img/feedback-1.png" alt="">
                        <button class="button button-order button-design">Хочу так же</button>
                    </div>
                </div>
                <div class="col-sm-5 col-sm-offset-1">
                    <p class="feedback-heading">
                    «...Получил я именно то, о чем мечтал, причем прямо с доставкой на дом!...»
                    </p>
                    <p class="feedback-text">
                    Я живу в небольшом поселке и для меня заказать портрет было целым подвигом! Пристал наши с
                    супругой фото и  попросил сделать несколько макетов на выбор. К моему удивлению, очень скоро
                    мне прислали не просто несколько,  а целых 15 разных макетов! Я выбрал самый лучший и оформил
                    заказ.
                    </p>
                    <p class="feedback-signature"><span>Олег Петров,</span> д. Месягутово</p>
                </div>
            </div>
            <div class="feedback-slider-item">
                <div class="col-sm-5">
                    <div class="feedback-block">
                        <img src="assets/img/feedback-2.jpg" alt="">
                        <button class="button button-order button-design">Хочу так же</button>
                    </div>
                </div>
                <div class="col-sm-5 col-sm-offset-1">
                    <p class="feedback-heading">
                    «...Я в восторге! Не думала, что портрет может быть настолько красивым!...»
                    </p>
                    <p class="feedback-text">
                    Подруге муж подарил портрет, и мне тоже захотелось заказать. Из всех макетов этот сразу запал
                    в душу! И  отлично вписывется в интерьер моей гостинной. Теперь он висит тут на почетном месте,
                    и я каждый раз любуюсь им. Всем моим родным тоже очень понравился!
                    </p>
                    <p class="feedback-signature"><span>Ирина Антонова,</span> г. Кисловодск</p>
                </div>
            </div>
            <div class="feedback-slider-item">
                <div class="col-sm-5">
                    <div class="feedback-block">
                        <img src="assets/img/feedback-3.jpg" alt="">
                        <button class="button button-order button-design">Хочу так же</button>
                    </div>
                </div>
                <div class="col-sm-5 col-sm-offset-1">
                    <p class="feedback-heading">
                    «...Неожиданный подарок на день рождения от друзей...»
                    </p>
                    <p class="feedback-text">
                    Хочу поделиться впечатлениями от полученного вчера подарка. Классная работа, выполненная,
                    натуральными  красками! Я как человек педантичный, люблю мелочи, для меня они очень важны,
                    и здесь они прикольно  сделаны – часы нарисовали, свитерок мой любимый, маечка с резиночкой.
                    Очень позитивный шарж, сделанный по моей фотографии.
                    </p>
                    <p class="feedback-signature"><span>Нарек Маргарян,</span> г. Великие Луки</p>
                </div>
            </div>
        </div>
    </div>
</div>

Создаем новый модуль slider.js в директории modules:

const slider = (slideSelector, direction, prevSelector, nextSelector) => {

    let index = 1, paused = false;
    const items = document.querySelectorAll(slideSelector);

    // инициализация слайдера сразу после загрузки страницы
    showSlide(index);

    // запускаем автоматическую смену слайдов после загрузки страницы
    autoplay();

    // когда мышь над слайдером — останавливаем автоматическую прокрутку
    items[0].parentNode.addEventListener('mouseenter', () => {
        clearInterval(paused);
    });
    // когда мышь покидает пределы слайдера — опять запускаем прокрутку
    items[0].parentNode.addEventListener('mouseleave', () => {
        autoplay();
    });

    // обработчики событий клика по кнопкам «вперед» и «назад»
    try {
        const prevButton = document.querySelector(prevSelector),
              nextButton = document.querySelector(nextSelector);

        prevButton.addEventListener('click', () => {
            nextPrevSlide(-1);
            items[index - 1].classList.remove('slideInLeft');
            // следующий слайд плавно выезжает справа
            items[index - 1].classList.add('slideInRight');
        });

        nextButton.addEventListener('click', () => {
            nextPrevSlide(1);
            items[index - 1].classList.remove('slideInRight');
            // следующий слайд плавно выезжает слева
            items[index - 1].classList.add('slideInLeft');
        });
    } catch(e) {
        console.log('У этого слайдера нет кнопок вперед и назад')
    }

    // скрыть все слайды и показать слайд с номером number
    function showSlide(number) {
        if (number > items.length) {
            index = 1;
        }
        if (number < 1) {
            index = items.length;
        }
        // скрыть все слайды...
        items.forEach(item => {
            item.classList.add('animated');
            item.style.display = 'none';
        });
        // ...и показать текущий
        items[index - 1].style.display = 'block';
    }

    // переход к следующему слайду при клике на кнопке «вперед» и «назад»
    function nextPrevSlide(n) {
        showSlide(index = index + n);
    }

    // автоматическая смена слайдов, когда мышь за пределами слайдера
    function autoplay() {
        if (direction === 'vertical') { // вертикальный 
            paused = setInterval(function() {
                nextPrevSlide(1);
                items[index - 1].classList.add('slideInDown');
            }, 3000);
        } else {
            paused = setInterval(function() { // горизонтальный
                nextPrevSlide(1);
                items[index - 1].classList.remove('slideInRight');
                items[index - 1].classList.add('slideInLeft');
            }, 3000);
        }
    }
};

export default slider;

И осталось только изменить src/js/main.js:

import modals from './modules/modals';
import slider from './modules/slider';

window.addEventListener('DOMContentLoaded', () => {
    'use strict';
    modals();
    slider('.feedback-slider-item', 'horizontal', '.main-prev-btn', '.main-next-btn');
    slider('.main-slider-item', 'vertical');
});

Отправка формы

Внутри каждого модального окна есть форма. Формы нужно отправлять посредством ajax и захватывать все введенные данные. У разных форм разные обработчики, так что надо отправлять их на разные URL. Один обработчик предназначен для обработки простой текстовой формы, а второй — для загрузки изображения. Кроме того, на странице есть две формы вне модального окна — их тоже нужно отправлять посредством ajax. Создаем новый модуль forms.js в директории modules:

const forms = () => {
    const allForms = document.querySelectorAll('form'),
          allInputs = document.querySelectorAll('input:not([type="file"]), textarea'),
          phoneInputs = document.querySelectorAll('input[name="phone"]'),
          allUploads = document.querySelectorAll('input[type="file"]');

    // сообщения при отправке данных формы на сервер
    const message = {
        loading: {
            txt: 'Отправка данных формы...',
            img: 'assets/img/loading.gif'
        },
        success: {
            txt: 'Спасибо! Скоро мы с вами свяжемся',
            img: 'assets/img/success.png'
        },
        failure: {
            txt: 'Что-то пошло не так...',
            img: 'assets/img/failure.png'
        }
    };

    // разные формы будем отправлять на разные url
    const handlers = {
        message: 'assets/message.php', // простая текстовая форма
        picture: 'assets/picture.php'  // форма загрузки изображения
    }

    // для полей ввода номера телефона удаляем все, кроме цифр
    phoneInputs.forEach(item => {
        item.addEventListener('input', () => {
            item.value = item.value.replace(/\D/, '');
        });
    });

    // при выборе файла показываем его имя в отдельном div-блоке,
    // как это предусмотено версткой; если имя длинное — обрезаем
    allUploads.forEach((item) => {
        item.addEventListener('input', () => {
            const orig = item.files[0].name; // оригинальное имя
            const parts = orig.split('.'); // имя и расширение
            const dots = parts[0].length > 10 ? '...' : '.';
            parts[0] = parts[0].length > 10 ? parts[0].substr(0, 10) : parts[0];
            const name = parts[0] + dots + parts[1]; // обрезанное имя
            // показываем имя выбраного файла в div-блоке
            item.previousElementSibling.textContent = name;
        });
    });

    // отправляет данные data на сервер, на указанный url
    const sendPostData = async (url, data) => {
        let response = await fetch(url, {
            method: 'POST',
            body: data
        });
        return await response.text();
    };

    // очищает все поля input всех форм на странице
    const clearInputs = () => {
        allInputs.forEach(item => {
            item.value = '';
        });
        allUploads.forEach(item => {
            // стандартный input[type="file"] тоже показывает имя файла
            item.value = '';
            // но мы для показа имени файла используем отдельный div
            item.previousElementSibling.textContent = 'Файл не выбран';
        });
    };

    // обработчик события отправки формы для всех форм на странице,
    // отправляет данные посредством ajax и показывает сообщения
    allForms.forEach(item => {
        item.addEventListener('submit', (e) => {
            e.preventDefault();
            // создаем контейнер, в котором показываем сообщение;
            // контейнер будет содержать абзац текста и картинку
            let statusContainer = document.createElement('div');
            item.parentNode.appendChild(statusContainer);
            statusContainer.style.display = 'none';
            // внутрь контейнера помещаем абзац текста и картинку
            let statusText = document.createElement('p');
            statusText.textContent = message.loading.txt;
            let statusImage = document.createElement('img');
            statusImage.setAttribute('src', message.loading.img);
            statusContainer.appendChild(statusText);
            statusContainer.appendChild(statusImage);
            // скрываем форму с использованием классов анимации
            item.classList.add('animated', 'fadeOutUp');
            item.addEventListener('animationend', function() {
                // чтобы форма не занимала место в модальном окне
                item.style.display = 'none';
                statusContainer.style.display = 'block';
                // показываем контейнер с использованием анимации
                statusContainer.classList.add('animated', 'fadeInDown');
            }, {once: true});

            // определяем, на какой url будем отправлять данные формы
            const picture = item.querySelector('input[type="file"]');
            const handler = picture ? handlers.picture : handlers.message;

            const formData = new FormData(item);
            sendPostData(handler, formData)
                .then(response => { // данные формы отправлены успешно
                    statusText.textContent = message.success.txt;
                    statusImage.setAttribute('src', message.success.img);
                    console.log(response);
                })
                .catch(() => { // произошла ошибка при отправке формы
                    statusText.textContent = message.failure.txt;
                    statusImage.setAttribute('src', message.failure.img);
                })
                .finally(() => { // скрыть сообщение, очистить все формы
                    clearInputs();
                    setTimeout(() => {
                        // плавно скрываем контейнер с текстом и картинкой,
                        // по окончании анимации — плавно показываем форму
                        statusContainer.classList.remove('fadeInDown');
                        statusContainer.classList.add('fadeOutUp');
                        statusContainer.addEventListener('animationend', function() {
                            // удаляем контейнер и плавно показываем форму
                            statusContainer.remove();
                            item.style.display = 'block';
                            item.classList.remove('fadeOutUp');
                            item.classList.add('fadeInDown');
                        }, {once: true});
                    }, 3000);
                });
        });
    });
};

export default forms;

В качестве основы мы используем модуль forms.js из первого проекта. Только теперь при отправке формы показываем не только текст, но и картинки. Кроме того, мы активно используем css-классы анимации из библиотеки animate.css. Обработчики данных формы на сервере просто отправляют полученные данные обратно.

<?php
// файл assets/message.php
$_POST['handler'] = $_SERVER['PHP_SELF'];
sleep(3);
echo var_dump($_POST);
<?php
// файл assets/picture.php
$_POST['handler'] = $_SERVER['PHP_SELF'];
sleep(3);
echo var_dump($_POST);
echo var_dump($_FILES);

И осталось только изменить src/js/main.js:

import modals from './modules/modals';
import slider from './modules/slider';
import forms from './modules/forms';

window.addEventListener('DOMContentLoaded', () => {
    'use strict';
    modals();
    slider('.feedback-slider-item', 'horizontal', '.main-prev-btn', '.main-next-btn');
    slider('.main-slider-item', 'vertical');
    forms();
});

Номер телефона

Формы содержат поле для ввода номера телефона, так что нужно ограничить ввод только цифрами. Кроме того, нужно красиво показывать введенные цифры, чтобы было понятно, что это номер телефона. Создаем новый модуль phones.js в директории modules:

const phones = (selector) => {
    // функция очищает и форматирует ввод номера телефона в поле
    // input, вызывается при событиях blur, focus, input
    function mask(event) {
        let phonePattern = '+7 (___) ___-__-__',
            // здесь будет только цифра(ы) телефонного кода страны
            countryCode = phonePattern.replace(/\D/g, ''),
            // из всего ввода в поле input оставляем только цифры
            onlyDigits = this.value.replace(/\D/g, ''),
            // позиция указателя на текущий символ в onlyDigits
            digitsIndex = 0;

        // пользователь еще не ввел ни одной цифры, в этом случае мы устанавливаем значение
        // onlyDigits равным countryCode; это позволит показать в поле ввода подсказку +7 —
        // пользователю будет понятно, что код города вводить не нужно
        if (countryCode.length >= onlyDigits.length) {
            onlyDigits = countryCode;
        }

        /*
         * Берем шаблон номера телефона +7 (___) ___-__-__ и проходим по всем символам этой
         * строки. Если очередной символ цифра (код города) или подчеркивание — заменяем его
         * на цифру из onlyDigits. Позицию очередного символа (цифры) из onlyDigits храним в
         * digitsIndex — и смещаем указатель при каждой замене подчеркивания на цифру.
         */
        let phoneValue = phonePattern.replace(/./g, function(a) {
            // если очередной символ является цифрой или подчеркиванием...
            let digitOrUnder = /[_0-9]/.test(a);
            // ...и мы еще не дошли до конца строки onlyDigits
            let notEndDigits = digitsIndex < onlyDigits.length;
            // ...то мы возвращаем очередной символ из onlyDigits
            if (digitOrUnder && notEndDigits) {
                // и смещаем указатель для работы со следующим
                return onlyDigits.charAt(digitsIndex++);
            }
            // если в onlyDigits все символы (то есть цифры) закончились, то
            // все что осталось в шаблоне phonePattern заменяем на пустоту; в
            // итоге получается +7 (926) 765-43-__ ——> +7 (926) 765-43
            if (digitsIndex >= onlyDigits.length) {
                return '';
            }
            // если очередной символ шаблона не был подчеркиваением и символы
            // (цифры) в onlyDigits еще не закончились — значит это пробел или
            // дефис или круглые скобки — их мы возвращаем без изменений
            return a;
        });

        // теперь заменяем введенное значение на строку шаблона, в которой мы
        // заменили все подчеркивания на цифры (или пробелы, если цифр мало)
        this.value = phoneValue;

        // если поле теряет фокус ввода — убираем из него +7, чтобы стало пустым
        if (event.type === 'blur') {
            if (this.value.length === 2) {
                this.value = '';
            }
        }
    }

    let phoneInputs = document.querySelectorAll(selector);

    phoneInputs.forEach(input => {
        // перед тем, как пользователь начнет вводить, перемещаем курсор в конец
        input.addEventListener('beforeinput', () => {
            input.setSelectionRange(input.value.length, input.value.length);
        });
        // функция mask очищает и форматирует ввод номера телефона в поле input
        input.addEventListener('input', mask);
        input.addEventListener('focus', mask);
        input.addEventListener('blur', mask);
    });
};

export default phones;

И осталось только изменить src/js/main.js:

import modals from './modules/modals';
import slider from './modules/slider';
import forms from './modules/forms';
import phones from './modules/phones';

window.addEventListener('DOMContentLoaded', () => {
    'use strict';
    modals();
    slider('.feedback-slider-item', 'horizontal', '.main-prev-btn', '.main-next-btn');
    slider('.main-slider-item', 'vertical');
    forms();
    phones('[name="phone"]');
});

Имя и фамилия

Формы содержат поле для ввода имени-фамилии, так что нужно ограничить ввод только кириллицей. Создаем новый модуль names.js в директории modules:

const names = (selector) => {
    const inputs = document.querySelectorAll(selector);

    inputs.forEach(input => {
        input.addEventListener('keydown', (event) => {
            const keys = ['Delete', 'Backspace', 'ArrowLeft', 'ArrowRight'];
            // разрешено использовать Delete, Backspace и стрелки
            if (keys.includes(event.key)) {
                return;
            }
            // разрешено вводить только кириллицу, пробел и дефис
            if (event.key && event.key.search(/[- а-яё]/i) === -1) {
                event.preventDefault();
            }
        });
        input.addEventListener('blur', (event) => {
            // если сработало автозаполнение в браузере
            if (input.value.search(/[^- а-яё]/i) >= 0) {
                input.value = 'Только кириллица';
                setTimeout(function () {
                    input.value = '';
                }, 1000);
            }
        });
    });
};

export default names;

И осталось только изменить src/js/main.js:

import modals from './modules/modals';
import slider from './modules/slider';
import forms from './modules/forms';
import phones from './modules/phones';
import names from './modules/names';

window.addEventListener('DOMContentLoaded', () => {
    'use strict';
    modals();
    slider('.feedback-slider-item', 'horizontal', '.main-prev-btn', '.main-next-btn');
    slider('.main-slider-item', 'vertical');
    forms();
    phones('[name="phone"]');
    names('[name="name"]');
});

Показ скрытых блоков

На странице есть блок «Популярные стили рисунка», где показываются образцы стилей — но изначально для просмотра доступна только часть стилей, остальные скрыты. При клике на кнопку «Посмотреть больше стилей» — должны быть показаны изначально скрытые стили.

<section class="styles" id="styles">
    <div class="container">
        <p class="p-heading">Портрет на холсте</p>
        <h2>Популярные стили рисунка</h2>
        <div class="row">
            <div class="col-sm-3 col-sm-offset-0 col-xs-10 col-xs-offset-1">
                <div class="styles-block">
                    <img src="assets/img/styles-1.jpg" alt="">
                    <h4>Маслом</h4>
                    <a href="#">Подробнее</a>
                </div>
            </div>
            <div class="col-sm-3 col-sm-offset-0 col-xs-10 col-xs-offset-1">
                <div class="styles-block">
                    <img src="assets/img/styles-2.jpg" alt="">
                    <h4>Акварелью</h4>
                    <a href="#">Подробнее</a>
                </div>
            </div>
            <div class="col-sm-3 col-sm-offset-0 col-xs-10 col-xs-offset-1">
                <div class="styles-block">
                    <img src="assets/img/styles-3.jpg" alt="">
                    <h4>Карандашом</h4>
                    <a href="#">Подробнее</a>
                </div>
            </div>
            <div class="col-sm-3 col-sm-offset-0 col-xs-10 col-xs-offset-1">
                <div class="styles-block">
                    <img src="assets/img/styles-4.jpg" alt="">
                    <h4>Ручкой</h4>
                    <a href="#">Подробнее</a>
                </div>
            </div>
            <div class="hidden-lg hidden-md hidden-sm hidden-xs styles-2">
                <div class="styles-block">
                    <img src="assets/img/styles-5.jpg" alt="">
                    <h4>Пастелью</h4>
                    <a href="#">Подробнее</a>
                </div>
            </div>
            <div class="hidden-lg hidden-md hidden-sm hidden-xs styles-2">
                <div class="styles-block">
                    <img src="assets/img/styles-6.jpg" alt="">
                    <h4>Поп-арт</h4>
                    <a href="#">Подробнее</a>
                </div>
            </div>
            <div class="hidden-lg hidden-md hidden-sm hidden-xs styles-2">
                <div class="styles-block">
                    <img src="assets/img/styles-7.png" alt="">
                    <h4>Фотомозаика</h4>
                    <a href="#">Подробнее</a>
                </div>
            </div>
            <div class="hidden-lg hidden-md hidden-sm hidden-xs styles-2">
                <div class="styles-block">
                    <img src="assets/img/styles-8.jpg" alt="">
                    <h4>Шарж</h4>
                    <a href="#">Подробнее</a>
                </div>
            </div>
        </div>
        <button class="button button-transparent button-styles">Посмотреть больше стилей</button>
    </div>
    <img class="paints" src="assets/img/paints.png" alt="">
</section>

Создаем новый модуль showMoreStyles.js в директории modules:

const showMoreStyles = (buttonSelector, hiddenSelector) => {
    const button = document.querySelector(buttonSelector),
          blocks = document.querySelectorAll(hiddenSelector);

    // для красивой анимации при показе ранее скрытых блоков
    blocks.forEach(item => {
        item.classList.add('animated', 'fadeInUp');
    });

    button.addEventListener('click', () => {
        // удаляем стили, которые отвечают за скрытие блоков и
        // добавляем стили, которые отвечают за показ блоков
        blocks.forEach(item => {
            item.classList.remove('hidden-lg', 'hidden-md', 'hidden-sm', 'hidden-xs');
            item.classList.add('col-sm-3', 'col-sm-offset-0', 'col-xs-10', 'col-xs-offset-1');
        });
        // саму кнопку удаляем со страницы
        button.remove();
    });

};

export default showMoreStyles;

И осталось только изменить src/js/main.js:

import modals from './modules/modals';
/* ... */
import showMoreStyles from './modules/showMoreStyles';

window.addEventListener('DOMContentLoaded', () => {
    'use strict';
    /* ... */
    showMoreStyles('.button-styles', '.styles-2');
});

Подгрузка данных с сервера

Допустим, что при клике на кнопку «Посмотреть больше стилей» нам нужно не показать скрытые блоки, а сначала подгрузить данные с сервера. Потом создать эти блоки, используя полученные от сервера данные, и только после этого — красиво показать. Создаем в директории src/assets файл resource.php, к которому будем выполнять запрос на получение данных.

<?php
header('Content-Type: application/json');
readfile('data.json');
{
    "styles": [
        {
            "src": "assets/img/styles-5.jpg",
            "title": "Пастель",
            "link": "#pastel"
        },
        {
            "src": "assets/img/styles-6.jpg",
            "title": "Поп-арт",
            "link": "#popart"
        },
        {
            "src": "assets/img/styles-7.png",
            "title": "Фотомозаика",
            "link": "#mozaika"
        },
        {
            "src": "assets/img/styles-8.jpg",
            "title": "Шарж",
            "link": "#sharj"
        }
    ]
}

Создаем директорию src/js/services, а внутри нее — файл request.js:

// отправляет данные data методом POST на указанный url
const sendPostData = async (url, data) => {
    let response = await fetch(url, {
        method: 'POST',
        body: data
    });
    return await response.text();
};

// запрашивает json-данные по указанному url методом GET
const getResource = async (url) => {
    let response = await fetch(url);
    if (!response.ok) {
        throw new Error(`Не удалось получить ответ от ${url}, статус ${response.status}`);
    }
    return await response.json();
};

export {sendPostData, getResource};

Мы вынесли в отдельный модуль отправку POST и GET запросов, так что можем теперь удалить из модуля forms.js фунцию sendPostData. Вместо этого мы можем ее импортировать из модуля services/request:

import {sendPostData} from '../services/request';

const forms = () => {
    /* ... */
};

export default forms;

Теперь поработаем над модулем showMoreStyles.js — для начала просто запросим с сервера json-данные:

import {getResource} from '../services/request';

const showMoreStyles = (buttonSelector) => {
    const button = document.querySelector(buttonSelector);

    button.addEventListener('click', () => {
        getResource('assets/resource.php')
            .then(response => console.log(response));
    });
};

export default showMoreStyles;

Все прошло успешно, данные успешно получены. Так что теперь можем создавать блоки динамически и потом показывать:

import {getResource} from '../services/request';

const showMoreStyles = (buttonSelector, wrapperSelector) => {
    const button = document.querySelector(buttonSelector),
          wrapper = document.querySelector(wrapperSelector);

    button.addEventListener('click', () => {
        getResource('assets/resource.php')
            .then(response => createBlocks(response.styles))
            .catch(error => console.log(error));
        button.remove();
    });

    function createBlocks(response) {
        response.forEach(item => {
            let block = document.createElement('div');
            block.classList.add(
                'col-sm-3',
                'col-sm-offset-0',
                'col-xs-10',
                'col-xs-offset-1',
                'animated',
                'fadeInUp'
            );
            let inner = `
            <div class="styles-block">
                <img src="${item.src}" alt="">
                <h4>${item.title}</h4>
                <a href="${item.link}">Подробнее</a>
            </div>
            `;
            block.innerHTML = inner;
            wrapper.appendChild(block);
        });
    }
};

export default showMoreStyles;

И осталось только изменить src/js/main.js:

import modals from './modules/modals';
/* ... */
import showMoreStyles from './modules/showMoreStyles';

window.addEventListener('DOMContentLoaded', () => {
    'use strict';
    /* ... */
    showMoreStyles('.button-styles', '#styles .row');
});

Расчет стоимости

На странице есть калькулятор — три выпадающих списка + поле для ввода промо-кода. После выбора значения в первом и втором списке — рассчитывается стоимость. Третий выпадающий список — необязательный, это дополнительные услуги. Если указан промокод — предоставляется скидка 30%. Промокод можно получить в модальном окне подарка.

<section class="calc">
    <div class="container">
        <p class="p-heading">Калькулятор</p>
        <h2>Рассчитайте стоимость и получите скидку 30%</h2>
        <div class="row">
            <div class="col-md-4 col-offset-1">
                <form class="form" action="" method="POST" enctype="multipart/form-data">
                    <h4>Загрузите свое фото</h4>
                    <div class="file_upload">
                        <button type="button">Загрузить фотографию</button>
                        <div>Файл не выбран</div>
                        <input type="file" name="upload">
                    </div>
                    <select id="size">
                        <option value="0">Выберите размер картины</option>
                        <option value="1000">40x50</option>
                        <option value="2000">50x70</option>
                        <option value="3000">70x70</option>
                        <option value="4000">70x100</option>
                    </select>
                    <select id="canvas">
                        <option value="0">Выберите материал картины</option>
                        <option value="1">Холст из волокна</option>
                        <option value="1.5">Льняной холст</option>
                        <option value="2">Холст из натурального хлопка</option>
                    </select>
                    <select id="option">
                        <option value="0">Дополнительные услуги</option>
                        <option value="500">Покрытие арт-гелем</option>
                        <option value="700">Экспресс-изготовление</option>
                        <option value="1000">Доставка готовых работ</option>
                    </select>
                    <input type="text" class="promocode" placeholder="Введите ваш промокод">
                    <div class="calc-price">Для расчета нужно выбрать размер картины и материал картины</div>
                    <button class="button button-order">Быстрый заказ</button>
                </form>
            </div>
            <div class="col-md-4 col-md-offset-2 hidden-sm hidden-xs">
                <img src="assets/img/calc-1.png" alt="">
                <p class="calc-text">Пропорциональный размер изображения рассчитывается автоматически.</p>
            </div>
        </div>
    </div>
</section>

Создаем модуль calc.js в директории modules:

const calc = (sizeSelector, canvasSelector, optionSelector, promoSelector, resultSelector) => {
    const sizeBlock = document.querySelector(sizeSelector),
          canvasBlock = document.querySelector(canvasSelector),
          optionBlock = document.querySelector(optionSelector),
          promoBlock = document.querySelector(promoSelector),
          resultBlock = document.querySelector(resultSelector);

    const showSum = () => {
        let size = parseFloat(sizeBlock.value),
            canvas = parseFloat(canvasBlock.value),
            option = parseFloat(optionBlock.value);

        let sum = Math.round(size * canvas + option);

        if (sizeBlock.value == '0' || canvasBlock.value == '0') {
            resultBlock.textContent = 'Пожалуйста, выберите размер и материал картины';
        } else if (promoBlock.value === 'IWANTPOPART') {
            resultBlock.textContent = Math.round(sum * 0.7);
        } else {
            resultBlock.textContent = sum;
        }
    };

    sizeBlock.addEventListener('change', showSum);
    canvasBlock.addEventListener('change', showSum);
    optionBlock.addEventListener('change', showSum);
    promoBlock.addEventListener('input', showSum);
};

export default calc;

Значение из первого выпадающего списка умножается на коэффициент из второго выпадающего списка. К полученному значению добавляется значение из третьего выпадающего списка. Все готово, осталось только изменить src/js/main.js:

import modals from './modules/modals';
/* ... */
import calc from './modules/calc';

window.addEventListener('DOMContentLoaded', () => {
    'use strict';
    /* ... */
    calc('#size', '#canvas', '#option', '.promocode', '.calc-price');
});

Фильтрация элементов

На странице есть галерея примеров выполненных портретов, которые можно фильтровать, кликая по ссылке вверху. Фильтрация организована очень просто — по наличию css-класса у подходящих портретов. Если каких-то портретов вообще нет — показывается сообщение «Таких портретов мы еще не делали».

<section class="portfolio" id="portfolio">
    <div class="container">
        <div class="p-heading">Примеры работ</div>
        <h2>Для кого делаем портрет?</h2>
        <ul class="portfolio-menu">
            <li class="all active">Все работы</li>
            <li class="love">Для влюбленных</li>
            <li class="chef">Для шефа</li>
            <li class="girl">Для девушки</li>
            <li class="guy">Для парня</li>
            <li class="grandmother">Для бабушки</li>
            <li class="grandfather">Для дедушки</li>
        </ul>
    </div>
    <div class="portfolio-wrapper">
        <div class="portfolio-block all girl">
            <img src="assets/img/portfolio-1.jpg" alt="">
        </div>
        <div class="portfolio-block all girl">
            <img src="assets/img/portfolio-2.jpg" alt="">
        </div>
        <div class="portfolio-block all love">
            <img src="assets/img/portfolio-3.jpg" alt="">
        </div>
        <div class="portfolio-block all guy">
            <img src="assets/img/portfolio-4.jpg" alt="">
        </div>
        <div class="portfolio-block all chef">
            <img src="assets/img/portfolio-5.jpg" alt="">
        </div>
        <div class="portfolio-block all love">
            <img src="assets/img/portfolio-6.jpg" alt="">
        </div>
        <div class="portfolio-block all love">
            <img src="assets/img/portfolio-7.jpg" alt="">
        </div>
        <div class="portfolio-block all girl">
            <img src="assets/img/portfolio-8.jpg" alt="">
        </div>
        <div class="portfolio-block all girl">
            <img src="assets/img/portfolio-9.jpg" alt="">
        </div>
        <div class="portfolio-block all chef">
            <img src="assets/img/portfolio-10.jpg" alt="">
        </div>
    </div>
    <div class="container">
        <p class="portfolio-no">Таких портретов мы еще не делали, хотите быть первыми?</p>
    </div>
</section>

Создаем модуль filter.js в директории modules:

const filter = () => {
    const menuWrapper = document.querySelector('.portfolio-menu'),
          menuElems = menuWrapper.querySelectorAll('li'),
          allButton = menuWrapper.querySelector('.all'),
          loveButton = menuWrapper.querySelector('.love'),
          chefButton = menuWrapper.querySelector('.chef'),
          girlButton = menuWrapper.querySelector('.girl'),
          guyButton = menuWrapper.querySelector('.guy'),
          grandmotherButton = menuWrapper.querySelector('.grandmother'),
          grandfatherButton = menuWrapper.querySelector('.grandfather');

    const portfolioWrapper = document.querySelector('.portfolio-wrapper'),
          allItems = portfolioWrapper.querySelectorAll('.all'),
          loveItems = portfolioWrapper.querySelectorAll('.love'),
          chefItems = portfolioWrapper.querySelectorAll('.chef'),
          girlItems = portfolioWrapper.querySelectorAll('.girl'),
          guyItems = portfolioWrapper.querySelectorAll('.guy'),
          grandmotherItems = portfolioWrapper.querySelectorAll('.grandmother'),
          grandfatherItems = portfolioWrapper.querySelectorAll('.grandfather');

    const emptyCollection = document.querySelector('.portfolio-no');

    const show = (collection) => {
        // пустая коллекция — показываем сообщение
        if (collection.length === 0) {
            emptyCollection.style.display = 'block';
        } else {
            emptyCollection.style.display = 'none';
        }
        // сначала скрываем все портреты...
        allItems.forEach(item => {
            item.style.display = 'none';
            item.classList.remove('animated', 'fadeIn');
        });
        // ...потом показываем только нужные
        collection.forEach(item => {
            item.style.display = 'block';
            item.classList.add('animated', 'fadeIn');
        });
    };

    allButton.addEventListener('click', () => {
        show(allItems); // все работы
    });
    loveButton.addEventListener('click', () => {
        show(loveItems); // для влюбленных
    });
    chefButton.addEventListener('click', () => {
        show(chefItems); // для шефа
    });
    girlButton.addEventListener('click', () => {
        show(girlItems); // для девушки
    });
    guyButton.addEventListener('click', () => {
        show(guyItems); // для парня
    });
    grandmotherButton.addEventListener('click', () => {
        show(grandmotherItems); // для бабушки
    });
    grandfatherButton.addEventListener('click', () => {
        show(grandfatherItems); // для дедушки
    });

    menuWrapper.addEventListener('click', (event) => {
        let target = event.target;
        if (target && target.tagName == 'LI') {
            menuElems.forEach(item => item.classList.remove('active'));
            target.classList.add('active');
        }
    });
};

export default filter;

Все готово, осталось только изменить src/js/main.js:

import modals from './modules/modals';
/* ... */
import filter from './modules/filter';

window.addEventListener('DOMContentLoaded', () => {
    'use strict';
    /* ... */
    filter();
});

Замена изображений при наведении

На странице есть блок, где показаны четыре серых прямоугольника — это холсты для портрета с указанием размера и цены. При наведении указателя мыши на любой из них — в прямоугольнике появляется портрет. Это что-то вроде предварительного просмотра — как будет выглядеть портрет на том или ином холсте.

<section class="sizes">
    <div class="container">
        <div class="p-heading">Популярные размеры и цены</div>
        <h2>Можем изготовить картину любого размера!</h2>
        <div class="col-md-10 col-md-offset-1 col-xs-12 col-xs-offset-0">
            <div class="sizes-wrapper">
                <div class="sizes-block">
                    <img class="size-1" src="assets/img/sizes-1.png" alt="">
                    <p class="size">40x50</p>
                    <p class="starting-price">5 290 руб.</p>
                    <p class="final-price">3 670 руб.</p>
                </div>
                <div class="sizes-block">
                    <img class="size-2" src="assets/img/sizes-2.png" alt="">
                    <p class="size">50x70</p>
                    <p class="starting-price">5 290 руб.</p>
                    <p class="final-price">3 670 руб.</p>
                </div>
                <div class="sizes-block">
                    <img class="size-3" src="assets/img/sizes-3.png" alt="">
                    <p class="size">70x70</p>
                    <p class="starting-price">5 290 руб.</p>
                    <p class="final-price">3 670 руб.</p>
                    <p class="sizes-hit">Хит продаж</p>
                </div>
                <div class="sizes-block">
                    <img class="size-4" src="assets/img/sizes-4.png" alt="">
                    <p class="size">70x100</p>
                    <p class="starting-price">5 290 руб.</p>
                    <p class="final-price">3 670 руб.</p>
                </div>
            </div>
        </div>
    </div>
</section>

Создаем модуль pictureSize.js в директории modules:

const pictureSize = (blockSelector) => {
    const blocks = document.querySelectorAll(blockSelector);

    const showImage = (block) => {
        const image = block.querySelector('img');
        // assets/img/sizes-1.png => assets/img/sizes-1-1.png
        image.src = image.src.replace('.png', '-1.png');
        // скрыть размер холста и цену, которые внутри <p>
        block.querySelectorAll('p:not(.sizes-hit)').forEach(item => {
            item.style.display = 'none';
        });
    };

    const hideImage = (block) => {
        const image = block.querySelector('img');
        // assets/img/sizes-1-1.png => assets/img/sizes-1.png
        image.src = image.src.replace('-1.png', '.png');
        // показать размер холста и цену, которые внутри <p>
        block.querySelectorAll('p:not(.sizes-hit)').forEach(item => {
            item.style.display = 'block';
        });
    };

    blocks.forEach(item => {
        item.addEventListener('mouseenter', () => {
            showImage(item);
        });
        item.addEventListener('mouseleave', () => {
            hideImage(item);
        });
    });
};

export default pictureSize;

Все готово, осталось только изменить src/js/main.js:

import modals from './modules/modals';
/* ... */
import pictureSize from './modules/pictureSize';

window.addEventListener('DOMContentLoaded', () => {
    'use strict';
    /* ... */
    pictureSize('.sizes .sizes-block');
});

Создаем аккордеон

На странице есть типичные вопросы, которые задают потенциальные покупатели — и ответы на эти вопросы. Каждый вопрос — это псевдо-ссылка, которая плавно показывает блок ответа ниже. Все остальные ответы в этот момент скрываются — в общем, типичный аккордеон.

<section class="often-questions" id="often-questions">
    <div class="container">
        <img class="spirt" src="assets/img/spirt.png" alt="">
        <p class="p-heading">Часто задаваемые вопросы</p>
        <h2>Ответим на любой из ваших вопросов</h2>
        <div id="accordion">
            <p class="accordion-heading col-md-8 col-md-offset-2">
                <span>Сколько времени нужно для изготовления заказа?</span>
            </p>
            <div class="col-md-8 col-md-offset-2 accordion-block">
                <p>
                    Время выполнения заказа зависит от трех факторов: сложности картины, стиля написания и размера.
                    Среднее время выполнения заказа составляет 7 дней без учета времени на доставку. В ряде случаев
                    по желанию заказчика можно уменьшить срок до 5 дней.
                    <br><br>
                    Примечание: Если Вы решите что-то изменить в картине, внести коррективы — это может занять
                    дополнительное время, которое не учитывается при оговоренном сроке выполнения.
                </p>
            </div>
            <p class="accordion-heading col-md-8 col-md-offset-2">
                <span>Смогу ли я увидеть картину до ее получения?</span>
            </p>
            <div class="col-md-8 col-md-offset-2 accordion-block">
                <p>
                    Да. По готовности, мы отправляем по электронной почте цифровое фото Вашей картины — для того,
                    чтобы Вы смогли, при необходимости, внести свои корректировки и пожелания, если таковые будут.
                    Вы также сможете увидеть картину «вживую» в нашем салоне. Сообщите об этом Вашему менеджеру.
                </p>
            </div>
            <p class="accordion-heading col-md-8 col-md-offset-2">
                <span>Какого формата должны быть фотографии</span>
            </p>
            <div class="col-md-8 col-md-offset-2 accordion-block">
                <p>
                    Мы работаем со всеми известными форматами изображений! Через сайт вы можете загрузить JPEG,
                    TIFF, PNG размером до 50 мегабайт. Вы должны обладать авторскими правами или иметь разрешение
                    на использование изображения. Если у Вас другой формат изображения (RAW, PDF), то вы можете
                    воспользоваться FTP загрузчиком. Если вы хотите сделать фото из бумажной фотографии (не цифровой),
                    пришлите его нам, мы отсканируем, обработаем и вернем фото вместе с вашим заказом!
                </p>
            </div>
            <p class="accordion-heading col-md-8 col-md-offset-2">
                <span>Могу ли я дать особые указания художнику</span>
            </p>
            <div class="col-md-8 col-md-offset-2 accordion-block">
                <p>
                    Когда вы размещаете свой заказ на сайте, вы можете добавить свои комментарии в специальном
                    текстовом поле. Вы также можете: объединить объекты с разных фото в одно или удалить объекты
                    с фото, можете поменять задний фон, поменять одежду, наряды, сделать цветную картину с
                    черно-белого фото, а также заказать картину с поврежденного или старого фото.
                </p>
            </div>
        </div>
        <img class="brushes" src="assets/img/brushes.png" alt="">
    </div>
</section>

Создаем модуль accordion.js в директории modules:

const accordion = (triggerSelector, contentSelector) => {
    const triggers = document.querySelectorAll(triggerSelector),
          contents = document.querySelectorAll(contentSelector);

    // для красивой анимации при показе ответа
    contents.forEach(item => {
        item.classList.add('animated', 'fadeInDown');
        item.style.display = 'none';
    });

    // сразу покажем ответ на первый вопрос
    contents[0].style.display = 'block';

    // обработчик события клика для каждого вопроса
    triggers.forEach(trigger => {
        trigger.addEventListener('click', () => {
            // ответы на все прочие вопросы скрываем...
            contents.forEach(content => {
                content.style.display = 'none';
            });
            // ...показываем только ответ на этот вопрос
            trigger.nextElementSibling.style.display = 'block';
        });
    });
};

export default accordion;

Все готово, осталось только изменить src/js/main.js:

import modals from './modules/modals';
/* ... */
import accordion from './modules/accordion';

window.addEventListener('DOMContentLoaded', () => {
    'use strict';
    /* ... */
    accordion('#accordion .accordion-heading', '#accordion .accordion-block');
});

Гамбургер-меню

На больших экранах вверху показывается меню из четырех пунктов, причем у каждого элемента меню есть подменю. На маленьких экранах меню скрывается, вместо него в левом верхнем углу показывается иконка гамбургера. При клике на иконку должно раскрываться другое меню — для мобильных устройств (которое на больших экранах скрыто). По сути, это меню для маленьких экранов является сокращенной версией меню для больших экранов.

<header class="header">
    <div class="container">
        <div class="col-md-2 col-sm-3 col-xs-6">
            <!-- иконка для маленьких экранов -->
            <button class="burger">
                <img src="assets/img/burger.svg" alt=""><span>Картины.art</span>
            </button>
            <!-- меню для маленьких экранов -->
            <ul class="burger-menu">
                <li><a href="#styles">Картина из вашего фото</a></li>
                <li><a href="#portfolio">Готовые картины</a></li>
                <li><a href="#portfolio">Идеи подарков</a></li>
                <li><a href="#scheme">Покупателям</a></li>
            </ul>
        </div>
        <div class="col-md-8 hidden-sm hidden-xs">
            <!-- меню для больших экранов -->
            <ul class="header-menu">
                <li>
                    <a href="#styles">Картина из вашего фото</a>
                    <ul class="burger-menu header-menu-sub">
                        <li><a href="#styles">Фотомозаика</a></li>
                        <li><a href="#styles">Шарж по фото</a></li>
                        <li><a href="#styles">Love is...</a></li>
                    </ul>
                </li>
                <li>
                    <a href="#portfolio">Готовые картины</a>
                    <ul class="burger-menu header-menu-sub">
                        <li><a href="#portfolio">Портрет</a></li>
                        <li><a href="#portfolio">Живопись</a></li>
                        <li><a href="#portfolio">Коллаж</a></li>
                    </ul>
                </li>
                <li>
                    <a href="#portfolio">Идеи подарков</a>
                    <ul class="burger-menu header-menu-sub">
                        <li><a href="#portfolio">Готовые картины</a></li>
                        <li><a href="#portfolio">Интерьерные постеры</a></li>
                    </ul>
                </li>
                <li>
                    <a href="#scheme">Покупателям</a>
                    <ul class="burger-menu header-menu-sub">
                        <li><a href="#scheme">Схема работы</a></li>
                        <li><a href="#often-questions">Вопросы и ответы</a></li>
                        <li><a href="#footer">Контакты</a></li>
                    </ul>
                </li>
            </ul>
        </div>
        <div class="col-md-2 col-md-offset-0 col-sm-3 col-sm-offset-6 col-xs-6">
            <div class="button-wrap">
                <button class="button button-order button-design">
                    Заказать<img src="assets/img/button-right-arrow.svg" alt="">
                </button>
            </div>
        </div>
    </div>
</header>

Нам надо навесить обработчик события клика на иконку гамбургера, чтобы показывать меню для мобильных, когда меню для десктопов скрыто. Создаем модуль accordion.js в директории modules:

const burger = (burgerIconSelector, burgerMenuSelector) => {
    const icon = document.querySelector(burgerIconSelector),
          menu = document.querySelector(burgerMenuSelector);

    const toggle = () => {
        if (getComputedStyle(icon).display != 'none') {
            if (getComputedStyle(menu).display == 'none') {
                menu.style.display = 'block';
            } else {
                menu.style.display = 'none';
            }
        }
    };

    icon.addEventListener('click', () => {
        toggle();
    });

    window.addEventListener('resize', () => {
        if (getComputedStyle(icon).display == 'none') {
            menu.style.display = 'none';
        }
    });
};

export default burger;

Все готово, осталось только изменить src/js/main.js:

import modals from './modules/modals';
/* ... */
import burger from './modules/burger';

window.addEventListener('DOMContentLoaded', () => {
    'use strict';
    /* ... */
    burger('.header .burger img', '.header .burger-menu');
})

Плавная прокрутка

Страница большая, поэтому верхнее меню содержит ссылки-якоря для быстрого перемещения в нужное место. Но резкое перемещение выглядит не очень красиво, хотелось бы иметь на странице плавную прокрутку к якорю. Кроме того, хотелось бы иметь плавную прокрутку в начало страницы с помощью стрелочки вверх в правом нижнем углу. Сначала создадим модуль scrollup.js, который будет прокручивать страницу вверх. Потом создадим еще один модуль scrolling.js, который будет прокручивать страницу к любому якорую.

<body>
    ..........
    <header class="header" id="page-top">
    ..........
    ..........
    ..........
    <a href="#page-top" class="scroll-up"></a>
    <script src="script.js"></script>
</body>
.scroll-up, .scroll-up:hover {
    width: 30px ;
    height: 30px;
    position: fixed;
    bottom: 30px;
    right: 30px;
    z-index: 30;
    color: #fff;
    background-color: #000;
    cursor: pointer;
    font-weight: bold;
    font-size: 30px;
    line-height: 30px;
    text-decoration: none;
    text-align: center;
}

Создаем модуль scrollup.js в директории modules:

const scrollup = (scrollupSelector) => {
    const scrollupButton = document.querySelector(scrollupSelector);

    scrollupButton.style.display = 'none';

    // кнопку прокрутки вверх показываем, когда страница прокручена на два экрана вниз
    window.addEventListener('scroll', () => {
        if (document.documentElement.scrollTop > document.documentElement.clientHeight * 2) {
            scrollupButton.style.display = 'block';
        } else {
            scrollupButton.style.display = 'none';
        }
    });

    // плавная прокрутка — это последовательность мелких шагов:
    // smoothScrollStep — кол-во пикселей, на которые смещаемся
    // betweenStepDelay — задержка между двумя смещениями, в мс
    const smoothScrollStep = 30, betweenStepDelay = 5;

    scrollupButton.addEventListener('click', function(event) {
        // запрещаем браузеру переход к якорю, будем делать это сами
        event.preventDefault();
        // анонимная функция запускается каждые betweenStepDelay мс,
        // прокручивая страницу на smoothScrollStep пикселей за раз
        let moving = setInterval(function() {
            // на каждом шаге — смещение на smoothScrollStep пикселей
            document.documentElement.scrollTop -= smoothScrollStep;
            // мы наверху страницы, останавливаем плавную прокрутку
            if (Math.round(document.documentElement.scrollTop) === 0) {
                clearInterval(moving);
            }
        }, betweenStepDelay);
    });
};

export default scrollup;
import modals from './modules/modals';
/* ... */
import scrollup from './modules/scrollup';

window.addEventListener('DOMContentLoaded', () => {
    'use strict';
    /* ... */
    scrollup('.scroll-up')
});

Создаем модуль scrolling.js в директории modules:

const scrolling = (scrollupSelector) => {
    const scrollupButton = document.querySelector(scrollupSelector),
          allAnchorLinks = document.querySelectorAll('a[href^="#"]');

    // кнопку прокрутки вверх показываем, когда страница прокручена на два экрана вниз
    scrollupButton.style.display = 'none';
    window.addEventListener('scroll', () => {
        if (document.documentElement.scrollTop > document.documentElement.clientHeight * 2) {
            scrollupButton.style.display = 'block';
        } else {
            scrollupButton.style.display = 'none';
        }
    });

    // плавная прокрутка — это последовательность мелких шагов:
    // smoothScrollStep — кол-во пикселей, на которые смещаемся
    // betweenStepDelay — задержка между двумя смещениями, в мс
    const smoothScrollStep = 30, betweenStepDelay = 5;

    allAnchorLinks.forEach(item => {
        item.addEventListener('click', (event) => {
            // запрещаем браузеру переход к якорю, будем делать это сами
            event.preventDefault();

            // на сколько пикселей вниз прокручена страница в данный момент —
            // это начальная позиция, откуда будем начинать плавную прокрутку
            let smoothScrollStart = Math.round(document.documentElement.scrollTop);
            // это элемент, к которому будем плавно прокручивать страницу
            let smoothScrollTarget = document.querySelector(item.hash);
            // это конечная позиция, где плавная прокрутка будет закончена
            let smoothScrollStop = 0;

            /*
            * В простейшем случае свойство offsetTop содержит смещение элемента относительно
            * body — что нам и нужно. Но если элемент имеет свойство position, отличное от 
            * static, нужно найти его предка, относительно которого он смещен — и получить
            * это смещение. Если у предка свойство position равно static — нужно получить
            * его смещение относительно body. Но если у предка свойство position отлично от
            * static — нужно найти его предка, относительно которого он смещен — и получить
            * это смещение. А потом получить смещение этого предка относительно body. Все
            * смещения суммируются и получаем в итоге смещение элемента относительно body.
            * +-------------------------------------------------+
            * | body                                            |
            * |  +-------------------------------------------+  |
            * |  | smoothScrollTarget.offsetParent           |  |
            * |  |  +-------------------------------------+  |  |
            * |  |  | smoothScrollTarget                  |  |  |
            * |  |  +-------------------------------------+  |  |
            * |  +-------------------------------------------+  |
            * +-------------------------------------------------+
            */
            while (smoothScrollTarget.offsetParent) {
                smoothScrollStop = smoothScrollStop + smoothScrollTarget.offsetTop;
                smoothScrollTarget = smoothScrollTarget.offsetParent;
            }

            smoothScrollStop = Math.round(smoothScrollStop);
            // вызываем функцию, которая выполняет плавную прокрутку
            smoothScroll(smoothScrollStart, smoothScrollStop, item.hash);
        });
    })

    // функция выполняет плавную прокрутку с позиции start до позиции stop
    const smoothScroll = (start, stop, hash) => {
        // к элементу footer страница в принципе не может быть прокручена, потому что он
        // расположен в самом низу страницы, а мы прокручиваем так, чтобы верх элемента 
        // был вверху окна браузера; в этом случае условие currentScrollTop === stop не
        // сработает — нужно еще одно условие, что мы «забуксовали» на месте
        let previousScrollTop;

        let moving = setInterval(function() {
            // на сколько пикселей вниз прокручена страница в данный момент
            let currentScrollTop = Math.round(document.documentElement.scrollTop);

            // мы на месте, останавливаем плавную прокрутку
            if (currentScrollTop === stop || currentScrollTop === previousScrollTop) {
                clearInterval(moving);
                history.replaceState(
                    history.state,
                    document.title, 
                    location.href.replace(/#.*$/g, '') + hash
                );
                return;
            }

            // за шаг мы смещаемся на smoothScrollStep, но когда мы уже близко к
            // цели, может потребоваться смещение меньше, чем smoothScrollStep
            let step = smoothScrollStep;
            if (Math.abs(currentScrollTop - stop) < smoothScrollStep) {
                step = Math.abs(currentScrollTop - stop);
            }

            // на каждом шаге — смещение на step пикселей вниз или вверх
            if (stop > start) {
                document.documentElement.scrollTop += step; // прокрутка вниз
            } else {
                document.documentElement.scrollTop -= step; // прокрутка вверх
            }

            // дополнительное условие остановки прокрутки для элемента footer
            previousScrollTop = currentScrollTop;
        }, betweenStepDelay);
    };
};

export default scrolling;
import modals from './modules/modals';
/* ... */
import scrollup from './modules/scrolling';

window.addEventListener('DOMContentLoaded', () => {
    'use strict';
    /* ... */
    scrolling('.scroll-up');
});
Свойство offsetParent содержит ближайшего родителя, относительно которого происходит позиционирование элемента. Это будет либо ближайший предок, у которого css-свойство position не равно static, либо тег body, если предка с таким позиционированием нет.

Когда в javascript-коде мы имеем дело с элементом <a>, получив этот элемент с помощью querySelector() — можно получить hash ссылки с помощью одноименного свойства.

let scrollupButton = document.querySelector('.scroll-up');
let scrollupButtonHash = scrollupButton.hash; // имеет значение #page-top
let pageTopElement = document.querySelector(scrollupButtonHash) // это <header id="page-top">

Этот модуль более универсальный — позволяет плавно прокручивать страницу к любому якорю. Так что необходимости в модуле scrollup.js больше нет и его можно удалить. И давайте реализуем альтернативный вариант модуля плавной прокрутки — с использованием window.requestAnimationFrame().

const scrolling2 = (scrollupSelector) => {
    const scrollupButton = document.querySelector(scrollupSelector),
          allAnchorLinks = document.querySelectorAll('a[href^="#"]');

    // кнопку прокрутки вверх показываем, когда страница прокручена на два экрана вниз
    scrollupButton.style.display = 'none';
    window.addEventListener('scroll', () => {
        if (document.documentElement.scrollTop > document.documentElement.clientHeight * 2) {
            scrollupButton.style.display = 'block';
        } else {
            scrollupButton.style.display = 'none';
        }
    });
    
    allAnchorLinks.forEach(item => {
        item.addEventListener('click', function(event) {
            // запрещаем браузеру переход к якорю, будем делать это сами
            event.preventDefault();

            let hash = this.hash;
            // положение элемента относительно окна браузера в пикселях: больше нуля,
            // если элемент ниже окна браузера и меньше нуля, если элемент выше окна
            let distWindowAnchor = document.querySelector(hash).getBoundingClientRect().top;
            // кол-во пикселей, на которые будем прокручивать страницу на каждом шаге
            let oneStepPixels = 100;
            // кол-во шагов, которые нужны, чтобы плавно прокрутить страницу к элементу
            let allStepCount = Math.ceil(Math.abs(distWindowAnchor / oneStepPixels));
            // текущий шаг, будем увеличивать значение, пока не достигнем allStepCount
            let curStepCount = 0;
            // за шаг мы смещаемся на oneStepPixels, но последний шаг может быть меньше
            let endStepPixels = Math.abs(Math.abs(distWindowAnchor) - oneStepPixels * allStepCount);

            requestAnimationFrame(smoothScroll);

            function smoothScroll() {
                curStepCount++;

                // на последнем шаге прокрутка может быть меньше, чем обычно
                let step = oneStepPixels;
                if (curStepCount === allStepCount) {
                    step = endStepPixels;
                }

                // очередной шаг вперед — чуть-чуть прокручиваем страницу
                if (distWindowAnchor > 0) { // прокрутка вниз
                    document.documentElement.scrollBy(0, step);
                } else { // прокрутка вверх
                    document.documentElement.scrollBy(0, -1 * step);
                }

                if (curStepCount < allStepCount) {
                    requestAnimationFrame(smoothScroll);
                } else {
                    location.hash = hash;
                }
            }
        });
    });
};

export default scrolling2;

Drag and Drop

При загрузке файлов нужно добавить возможность перетаскивать файлы мышкой из операционной системы в окно браузера. Область, где можно бросить файл, должна быть подсвечена — это подсказка пользователю. Создаем модуль drugAndDrop.js в директории modules:

const dragAndDrop = () => {
    /*
     * Ряд событий срабатывают на протяжении всей процедуры drag and drop. Только drag-события
     * срабатывают на протяжении операции перемещения; события мыши, такие как mousemove — нет.
     * События dragstart и dragend не срабатывают при переносе файла из операционной системы 
     * в браузер. Вместо этого при переносе файла из ОС следует использовать события dragenter,
     * dragleave и dragover.
     * 
     * Событие dragenter срабатывает, когда перемещаемый элемент входит в зону, принимающей
     * перетаскиваемые элементы.
     * 
     * Событие dragleave срабатывает, когда перемещаемый элемент выходит за пределы зоны,
     * принимающей перетаскиваемые элементы.
     * 
     * Событие dragover срабатывает каждые несколько сотен миллисекунд, когда перемещаемый
     * элемент оказывается над зоной, принимающей перетаскиваемые элементы. 
     * 
     * Событие drop вызывается для элемента, над которым произошло сбрасывание перемещаемого
     * элемента.
     */
    const fileInputs = document.querySelectorAll('[name="upload"]');

    // браузер имеет свой собственный drag and drop, который запускается автоматически и
    // будет конфликтовать с нашей реализацией, поэтому отключаем поведение по умолчанию
    ['dragenter', 'dragleave', 'dragover', 'drop'].forEach(eventName => {
        fileInputs.forEach(input => {
            input.addEventListener(eventName, (event) => {
                event.preventDefault();
                event.stopPropagation();
            });
        });
    });

    // когда перетаскиваемый файл находится над элементом input[type="file"], мы
    // выделяем область, где можно этот файл бросить — это подсказка пользователю
    ['dragenter', 'dragover'].forEach(eventName => {
        fileInputs.forEach(input => {
            input.addEventListener(eventName, () => {
                input.closest('.file_upload').style.border = '1px solid red';
                input.closest('.file_upload').style.backgroundColor = 'yellow';
            });
        });
    });

    // когда перетаскиваемый файл покидает элемент input[type="file"] или пользователь
    // бросает файл, мы снимаем выделение области, которое создали ранее
    ['dragleave', 'drop'].forEach(eventName => {
        fileInputs.forEach(input => {
            input.addEventListener(eventName, () => {
                input.closest('.file_upload').style.border = 'none';
                input.closest('.file_upload').style.backgroundColor = 'transparent';
            });
        });
    });

    fileInputs.forEach(input => {
        input.addEventListener('drop', (event) => {
            /*
             * Объект DataTransfer используется для хранения данных, перетаскиваемых мышью во
             * время операции drag and drop. Объект может быть получен из свойства dataTransfer
             * всех событий перетаскивания. Он не может быть отдельно создан. Свойство files
             * содержит список локальных файлов, которые перетаскиваются из ОС в браузер.
             */
            input.files = event.dataTransfer.files;

            // обрезаем оригинальное имя файла в операционной системе
            const orig = input.files[0].name; // оригинальное имя
            const parts = orig.split('.'); // имя и расширение
            const dots = parts[0].length > 10 ? '...' : '.';
            parts[0] = parts[0].length > 10 ? parts[0].substr(0, 10) : parts[0];
            const name = parts[0] + dots + parts[1]; // обрезанное имя
            // показываем имя выбраного файла в div-блоке
            input.previousElementSibling.textContent = name;
        });
    });
};

export default dragAndDrop;
import modals from './modules/modals';
/* ... */
import dragAndDrop from './modules/dragAndDrop';

window.addEventListener('DOMContentLoaded', () => {
    'use strict';
    /* ... */
    dragAndDrop();
});

Дополнительно

Демо-сайт здесь, исходные коды здесь.

Поиск: JavaScript • Web-разработка • Модуль • Практика

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.