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

24.05.2021

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

Во третьей части будем дорабатывать третий проект. У нас есть «голая» верстка, но нет еще js-кода, который бы оживил страницу. В проекте есть две страницы index.html и modules.html, каждая страница представляет из себя набор блоков. Страница index.html должна быть реализована в виде большого слайдера, где каждый блок является как бы «страницей» и эти «страницы» можно листать. Перелистывание «страниц» возможно только вперед при помощи клика на стрелку в левом нижнем углу. При клике на логотип в левом верхнем углу происходит переход к первой «странице».

Файл index.html

<body>
    <!-- слайдер «страниц» -->
    <div class="page">
        <!-- отдельные «страницы» -->
        <div class="showup">.....</div> 
        <div class="difference">.....</div>
        <div class="modules">.....</div>
        <div class="join">.....</div>
        <div class="feed">.....</div>
        <div class="schedule">.....</div>
    </div>
    <div class="overlay">.....</div>
    <script src="script.js"></script>
</body>

Большой слайдер «страниц»

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

export default class Slider {

    constructor(container, controls) {
        this.container = document.querySelector(container); // контейнер слайдов
        this.slides = this.container.children; // все слайды слайдера
        this.controls = document.querySelectorAll(controls); // кнопки управления
        this.slideIndex = 1; // номер текущего слайда
    }

    // показывает слайд с номером number
    showSlide(number) {
        // если номер слайда выходит за пределы
        if (number > this.slides.length) {
            this.slideIndex = 1;
        }
        if (number < 1) {
            this.slideIndex = this.slides.length;
        }
        // сначала скрываем все слайды...
        for (let i = 0; i < this.slides.length; i++) {
            this.slides[i].style.display = 'none';
        }
        // ...потом показываем нужный
        this.slides[this.slideIndex - 1].style.display = 'block';
    }

    // смещает указатель текущего слайда на число number
    shiftSlider(number = 1) {
        this.slideIndex = this.slideIndex + number;
        this.showSlide(this.slideIndex);
    }

    // выполняет всю работу по подготовке слайдера к работе
    // и показывает слайд с номером slideIndex
    render() {
        this.controls.forEach(item => {
            // при клике по стрелке, переход к следующему слайду
            item.addEventListener('click', () => {
                this.shiftSlider();
            });
            // переход к первому слайду при клике на логотип
            item.parentNode.previousElementSibling.addEventListener('click', (event) => {
                event.preventDefault();
                this.slideIndex = 1;
                this.showSlide(this.slideIndex);
            });
        });

        // показать слайд с номером slideIndex
        this.showSlide(this.slideIndex);
    }
}

Создаем файл src/js/main.js, где будем подключать все модули:

import Slider from './modules/Slider';

window.addEventListener('DOMContentLoaded', () => {
    new Slider('.page', '.next').render();
});

Показ блока с задержкой

На третьей «странице» есть блок, который при загрузке страницы index.html необходимо скрыть. А после того, как пользователь попадет на третью «страницу» и проведет на ней три секунды — показать.

export default class Slider {

    constructor(container, controls) {
        /* ..... */
        this.showHanson();
    }

    // показывает слайд с номером number
    showSlide(number) {
        /* ..... */
    }

    // скрывает блок с css-классом hanson, который расположен на третьем слайде
    // и показывает его через три секунды после перехода к третьему слайду
    showHanson() {
        this.hanson = document.querySelector('.hanson');
        if (!this.hanson) {
            return;
        }
        this.hanson.style.opacity = '0';
        this.hanson.classList.add('animated');
        // каждые 100 мс запускаем анонимную функцию, которая будет проверять
        // значение номера текущего слайда; если это третий слайд — через три
        // секунды блок .hanson будет показан, иначе этот блок будет скрыт
        setInterval(() => {
            if (this.slideIndex === 3) {
                setTimeout(() => {
                    this.hanson.style.opacity = '1';
                    this.hanson.classList.add('slideInUp');
                }, 3000);
            } else {
                this.hanson.style.opacity = '0';
                this.hanson.classList.remove('slideInUp');
            }
        }, 100);
    }

    // смещает указатель текущего слайда на число number
    shiftSlider(number = 1) {
        /* ..... */
    }

    // выполняет всю работу по подготовке слайдера к работе
    // и показывает слайд с номером slideIndex
    render() {
        /* ..... */
    }
}

Видео в модальном окне

На первой «странице» есть кнопка с css-классом play, атрибут data-url содержит идентификатор видео, размещенного на YouTube.

<div data-url="vZ4Sne0wdxY" class="play">
    <div class="play__circle">
        <svg viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg">
            <path d="M14 8L0 16V0L14 8Z" fill="#6D53AF"/>
        </svg>
    </div>
    <div class="play__text">why</div>
</div>

При клике на эту кнопку нужно показать блок с css-классом overlay и воспроизвести видео. Скрипт должен быть универсальным, чтобы его можно было использовать повторно.

<div class="overlay">
    <div class="video">
        <div id="frame"></div>
        <div class="close">&times;</div>
    </div>
</div>

Нам потребуется YouTube Player API для создания проигрывателя видео, документацию к которому можно прочитать здесь. При создании проигрывателя на место блока div#frame будет вставлен фрейм iframe#frame, в котором и будет воспроизводиться видео.

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

export default class VideoPlayer {

    constructor(trigger, overlay) {
        this.trigger = document.querySelector(trigger);
        this.overlay = document.querySelector(overlay);
        this.close = this.overlay.querySelector('.close');
        this.player = undefined;
    }

    // обработка события клика для кнопки запуска видео
    bindTrigger() {
        this.trigger.addEventListener('click', () => {
            this.overlay.style.display = 'flex';
            // пользователь может кликнуть по кнопке несколько раз;
            // проверяем, чтобы каждый раз не создавать новый плеер 
            if (this.player === undefined) {
                this.createPlayer(this.trigger.dataset.url);
            }
        });
    }

    // при клике на крестик убираем overlay и останавливаем видео
    bindClose() {
        this.close.addEventListener('click', () => {
            this.overlay.style.display = 'none';
            this.player.pauseVideo(); // или this.player.stopVideo();
        });
    }

    // создаем проигрыватель с использованием YouTube Player API
    createPlayer(id) {
        this.player = new YT.Player('frame', {
            height: '100%',
            width: '100%',
            videoId: id
        });
    }

    // инициализация проигрывателя, подключение API и обработчики событий
    init() {
        // подключаем YouTube Player API
        const tag = document.createElement('script');
        tag.src = 'https://www.youtube.com/iframe_api';
        const firstScriptTag = document.getElementsByTagName('script')[0];
        firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
        // обработчик события клика для кнопки запуска видео
        this.bindTrigger();
        // обработчик события клика на кнопку крестика (закрыть)
        this.bindClose();
    }
}

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

import Slider from './modules/Slider';
import VideoPlayer from './modules/VideoPlayer';

window.addEventListener('DOMContentLoaded', () => {
    new Slider('.page', '.next').render();
    new VideoPlayer('.play', '.overlay').init();
});

Но у нас еще есть задача сделать скрипт универсальным — подразумевается, что на странице может быть несколько кнопок для просмотра разных видео. Тут трудность в том, что при создании проигрывателя элемент div#frame заменяется на iframe#frame — и новый плеер нельзя создать, потому что его некуда вставлять на страницу. Так что перед созданием нового плеера нужно удалить iframe#frame и восстановить элемент div#frame.

<div data-url="vZ4Sne0wdxY" class="play">...</div>
<div data-url="uBpVXkEDALM" class="play">...</div>
export default class VideoPlayer {

    constructor(triggers, overlay) {
        this.triggers = document.querySelectorAll(triggers);
        this.overlay = document.querySelector(overlay);
        this.close = this.overlay.querySelector('.close');
        this.player = undefined;
    }

    // обработка события клика для всех кнопок запуска видео
    bindTriggers() {
        this.triggers.forEach(item => {
            item.addEventListener('click', () => {
                this.overlay.style.display = 'flex';
                // если плеер уже был создан ранее с другим видео, нужно удалить
                // iframe#frame и восстановить div#frame, чтобы создать новый
                if (this.player) {
                    this.player.destroy();
                }
                this.createPlayer(item.dataset.url);
            });
        });
    }

    // при клике на крестик убираем overlay и останавливаем видео
    bindClose() {
        this.close.addEventListener('click', () => {
            this.overlay.style.display = 'none';
            this.player.stopVideo();
        });
    }

    // создаем проигрыватель с использованием YouTube Player API
    createPlayer(id) {
        this.player = new YT.Player('frame', {
            height: '100%',
            width: '100%',
            videoId: id
        });
    }

    // инициализация проигрывателя, подключение API и обработчики событий
    init() {
        // подключаем YouTube Player API
        const tag = document.createElement('script');
        tag.src = 'https://www.youtube.com/iframe_api';
        const firstScriptTag = document.getElementsByTagName('script')[0];
        firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
        // обработчики события клика для всех кнопок запуска видео
        this.bindTriggers();
        // обработчик события клика на кнопку крестика (закрыть)
        this.bindClose();
    }
}

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

Множество слайдеров

Кроме главного слайдера «страниц» есть еще несколько слайдеров поменьше. Поэтому мы создадим класс Slider, в котором будет функционал, общий для всех слайдеров. И создадим несколько дочерних классов, где будет только функционал, характерный для конкретного слайдера. Создадим директорию src/modules/slider и разместим в ней два файла — Slider.js (родитель для всех слайдеров) и MainSlider.js (слайдер «страниц»).

export default class Slider {

    constructor({container, next, prev} = {}) {
        this.container = document.querySelector(container); // контейнер слайдов
        this.slides = this.container.children; // все слайды слайдера
        this.next = document.querySelectorAll(next); // кнопки вперед
        this.prev = document.querySelectorAll(prev); // кнопки назад
        this.slideIndex = 0; // индекс текущего слайда, начиная с нуля
    }

    // показывает слайд с индексом slideIndex
    showSlide() {
        // если индекс слайда выходит за пределы
        if (this.slideIndex >= this.slides.length) {
            this.slideIndex = 0;
        }
        if (this.slideIndex < 0) {
            this.slideIndex = this.slides.length - 1;
        }
        // сначала скрываем все слайды...
        for (let i = 0; i < this.slides.length; i++) {
            this.slides[i].style.display = 'none';
        }
        // ...потом показываем нужный
        this.slides[this.slideIndex].style.display = 'block';
    }

    // смещает указатель текущего слайда на число number; число может
    // быть как положительным (вперед), так и отрицательным (назад)
    shiftSlider(number = 1) {
        this.slideIndex = this.slideIndex + number;
        this.showSlide();
    }

    // выполняет всю работу по подготовке слайдера к работе
    // и показывает первый слайд
    render() {
        this.next.forEach(item => {
            // при клике «вперед» — переход к следующему слайду
            item.addEventListener('click', () => {
                this.shiftSlider();
            });
        });

        this.prev.forEach(item => {
            // при клике «назад» — переход к предыдущему слайду
            item.addEventListener('click', () => {
                this.shiftSlider(-1);
            });
        });

        // показать первый слайд (с индексом ноль)
        this.showSlide();
    }
}
import Slider from './Slider';

export default class MainSlider extends Slider {

    constructor({container, next}) {
        super({container: container, next: next});
        this.logo = document.querySelector('.sidecontrol > a');
        this.showHanson();
    }

    // скрывает блок с css-классом hanson, который расположен на третьем слайде
    // и показывает его через три секунды после перехода к третьему слайду
    showHanson() {
        let hanson = document.querySelector('.hanson');
        if (!hanson) {
            return;
        }
        hanson.style.opacity = '0';
        hanson.classList.add('animated');
        // каждые 100 мс запускаем анонимную функцию, которая будет проверять
        // значение номера текущего слайда; если это третий слайд — через три
        // секунды блок .hanson будет показан, иначе этот блок будет скрыт
        setInterval(() => {
            if (this.slideIndex === 2) {
                setTimeout(() => {
                    hanson.style.opacity = '1';
                    hanson.classList.add('slideInUp');
                }, 3000);
            } else {
                hanson.style.opacity = '0';
                hanson.classList.remove('slideInUp');
            }
        }, 100);
    }

    render() {
        super.render();
        // переход к первому слайду при клике на логотип
        this.logo.addEventListener('click', (event) => {
            event.preventDefault();
            this.slideIndex = 0;
            this.showSlide();
        });
    }
}

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

import MainSlider from './modules/slider/MainSlider';
import VideoPlayer from './modules/VideoPlayer';

window.addEventListener('DOMContentLoaded', () => {
    new MainSlider({
        container: 'body > .page',
        next: '.sidecontrol__controls > .next'
    }).render();

    new VideoPlayer('.showup .play', '.overlay').init();
});

Теперь нам нужен класс для еще трех слайдеров, которые есть на странице. Создаем файл MiniSlider.js в директории src/js/modules/slider.

import Slider from './Slider';

export default class MiniSlider extends Slider {

    constructor({container, next, prev, autoplay, current}) {
        super({container, next, prev});
        this.autoplay = autoplay ? true : false;
        // если пользователь хочет сам управлять покруткой слайдера, то
        // autoplay будет ему мешать; поэтому автопрокрутка не будет
        // работать, когда указатель мыши над блоком wrapper, который
        // содержит сам слайдер и кнопки управления
        this.playing = undefined;
        this.wrapper = this.container.parentNode;
        // дополнительный css-класс для активного слайда
        this.current = current;
    }

    // выполняет всю работу по подготовке слайдера к работе
    render() {
        this.next.forEach(item => {
            item.addEventListener('click', () => {
                this.forward();
            });
        });

        this.prev.forEach(item => {
            item.addEventListener('click', () => {
                this.backward();
            });
        });

        // запускаем автоматическую прокрутку
        if (this.autoplay) {
            this.startPlay();
            // когда мышь над блоком wrapper — останавливаем автоматическую прокрутку
            this.wrapper.addEventListener('mouseenter', () => {
                this.stopPlay();
            });
            // когда мышь покидает блок wrapper — запускаем автоматическую прокрутку
            this.wrapper.addEventListener('mouseleave', () => {
                this.startPlay();
            });
        }
    }

    // запускает автоматическую прокрутку
    startPlay() {
        this.playing = setInterval(() => {
            this.forward();
        }, 3000);
    }

    // останавливает автоматическую прокрутку
    stopPlay() {
        clearInterval(this.playing);
    }

    // вперед — первая картинка помещается после последней
    forward() {
        this.container.append(this.slides[0]);
        this.active();
    }

    // назад — последняя картинка помещается перед первой
    backward() {
        this.container.prepend(this.slides[this.slides.length - 1]);
        this.active();
    }

    // добавляет дополнительный css-класс для первого слайда
    active() {
        for (let i = 0; i < this.slides.length; i++) {
            this.slides[i].classList.remove(this.current);
        }
        this.slides[0].classList.add(this.current);
    }
}

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

import MainSlider from './modules/slider/MainSlider';
import MiniSlider from './modules/slider/MiniSlider';
import VideoPlayer from './modules/PlayVideo';

window.addEventListener('DOMContentLoaded', () => {
    new MainSlider({
        container: 'body > .page',
        next: '.sidecontrol__controls > .next'
    }).render();

    new MiniSlider({
        container: '.showup__content-slider',
        prev: '.showup__prev',
        next: '.showup__next',
        autoplay: true,
        current: 'card-active'
    }).render();
    new MiniSlider({
        container: '.modules__content-slider',
        prev: '.modules__info-btns .slick-prev',
        next: '.modules__info-btns .slick-next',
        autoplay: true,
        current: 'card-active'
    }).render();
    new MiniSlider({
        container: '.feed__slider',
        prev: '.feed__slider .slick-prev',
        next: '.feed__slider .slick-next',
        autoplay: true,
        current: 'feed__item-active',
    }).render();

    new VideoPlayer('.showup .play', '.overlay').init();
});

Все вроде хорошо, но третий слайдер содержит внутри контейнера для слайдов еще и кнопки «вперед» и «назад». Так что они тоже прокручиваются и к ним добавляется дополнительный css-класс, как для первого слайда. Давайте это исправим — изменим методы forward() и backward().

export default class MiniSlider extends Slider {
    /* ..... */
    forward() {
        while (this.slides[1].tagName === 'BUTTON') {
            this.container.append(this.slides[1]);
        }
        this.container.append(this.slides[0]);
        this.active();
    }

    backward() {
        for (let i = this.slides.length - 1; i > 0; i--) {
            if (this.slides[i].tagName !== 'BUTTON') {
                this.container.prepend(this.slides[i]);
                break;
            }
        }
        this.active();
    }
    /* ..... */
}

Есть еще одна проблема — с автоматической прокруткой второго слайдера. Там контейнер this.wrapper, который должен включать в себя контейнер для слайдов + кнопки «вперед» и «назад» — не содержит внутри себя кнопки управления. Пока указатель мыши находится над слайдером — автоматическая прокрутка останавливается. А если указатель мыши перемещается в область кнопок «вперед» и «назад» — автоматическая прокрутка возобновляется. Конечно, это можно исправить, но поленился это делать — в этой ситуации виноват верстальщик, который разбрасывает элементы по всей странице не задумываясь. Было бы правильно придерживаться какого-то единого шаблона верстки слайдеров.

<div class="slider-wrapper">
    <div class="slider-elements">
        <div class="slider-element">Первый элемент слайдера</div>
        <div class="slider-element">Второй элемент слайдера</div>
        <div class="slider-element">Третий элемент слайдера</div>
    </div>
    <div class="slider-controls">
        <span class="slider-prev">...</span>
        <span class="slider-next">...</span>
    </div>
</div>

Иначе получается, что под каждый слайдер надо создавать отдельный класс, в котором с помощью костылей закрываются баги верстки. Так что для второго слайда просто отключил автоматическую прокрутку.

Показ блока по клику

На второй «странице» есть список различий «The difference». Это две колонки вертикально расположенных блоков — как было и как стало. Левая колонка — как было раньше, правая колонка — как стало сейчас. Блоки правой колонки изначально должны быть скрыты, и появляться по одному при клике на блок «Click to show». Когда показан последний блок в правой колонке — блок «Click to show» пропадает.

<div class="difference__wrapper">
    <div class="difference__info">
        <div class="title">The difference</span></div>
        <div class="subtitle">Between education 10 years ago and today</div>
        <div class="difference__info-cards">
            <!-- блок, как было раньше — левая колонка -->
            <div class="officerold">
                <div class="officer__card-title">Education 10 years ago</div>
                <!-- блоки левой колонки изначально видны -->
                <div class="officer__card-item">.....</div>
                <div class="officer__card-item">.....</div>
                <div class="officer__card-item">.....</div>
            </div>
            <!-- блок, как стало сейчас — правая колонка -->
            <div class="officernew">
                <div class="officer__card-title">Education today</div>
                <!-- блоки правой колонки нужно изначально скрыть -->
                <div class="officer__card-item">.....</div>
                <div class="officer__card-item">.....</div>
                <div class="officer__card-item">.....</div>
                <!-- при клике появляются блоки правой колонки -->
                <div class="officer__card-item">Click to show</div>
            </div>
        </div>
    </div>
    <div class="difference__photo"></div>
</div>

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

export default class Difference {
    constructor(blockSelector, triggerSelector) {
        this.block = document.querySelectorAll(blockSelector);
        this.trigger = document.querySelector(triggerSelector);
        this.counter = 0;
    }

    init() {
        this.block.forEach(item => {
            item.style.display = 'none';
        });
        this.trigger.addEventListener('click', (event) => {
            if (this.counter < 3) {
                this.block[this.counter].style.display = 'flex';
                this.counter++;
                if (this.counter === 3) {
                    event.currentTarget.style.display = 'none';
                }
            }
        });
    }
}

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

/* ..... */
import Difference from './modules/difference';

window.addEventListener('DOMContentLoaded', () => {
    /* ..... */
    new Difference(
        '.officernew > .officer__card-item:not(:last-child)',
        '.officernew > .officer__card-item:last-child',
    ).init();
});

Отправка данных формы

Необходимо реализовать отправку двух формы без перезагрузки страницы, так что создаем файл Forms.js в директории src/js/modules.

export default class Forms {
    constructor() {
        // формы на странице, с которыми будем работать
        this.forms = document.querySelectorAll('form');
        // все поля всех форм, с которыми будем работать
        this.inputs = document.querySelectorAll('input'),
        // сообщения при отправке данных формы на сервер
        this.message = {
            loading: 'Отправка данных формы...',
            success: 'Спасибо! Скоро мы с вами свяжемся',
            failure: 'Что-то пошло не так...'
        };
        // путь к обработчику данных на сервере
        this.handler = 'assets/question.php';
    }

    async postData(url, data) {
        let res = await fetch(url, {
            method: 'POST',
            body: data
        });
        return await res.text();
    }

    // очищает все поля input всех форм на странице
    clearInputs() {
        this.inputs.forEach(item => {
            item.value = '';
        });
    };

    init() {
        this.forms.forEach(form => {
            form.addEventListener('submit', (event) => {
                event.preventDefault();
                // создаем div-блок, в котором показываем сообщение
                let statusMessage = document.createElement('div');
                statusMessage.style.cssText = `
                    margin: 15px 0;
                    color: #f99;
                `;
                statusMessage.textContent = this.message.loading;
                form.before(statusMessage);
                // получаем данные формы для отправки на сервер
                const formData = new FormData(form);
                // отправляем данные формы обработчику на сервере
                this.postData(this.handler, formData)
                    .then(response => { // данные формы отправлены успешно
                        // console.log(response);
                        statusMessage.textContent = this.message.success;
                    })
                    .catch(() => statusMessage.textContent = this.message.failure) // ошибка
                    .finally(() => { // скрыть сообщение, очистить все формы
                        this.clearInputs();
                        setTimeout(() => {
                            statusMessage.remove();
                        }, 5000);
                    });
            });
        });
    }
}

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

/* ..... */
import Forms from './modules/Forms';

window.addEventListener('DOMContentLoaded', () => {
    /* ..... */
    new Forms('form').init();
});

Но это еще не все, необходимо проверять ввод в поле адреса почты и создать маску для ввода номера телефона. Собственно, мы такие задачи уже решали во второй части, так что возьмем код оттуда и адаптируем под наши нужды.

export default class Forms {
    constructor() {
        // формы на странице, с которыми будем работать
        this.forms = document.querySelectorAll('form');
        // все поля всех форм, с которыми будем работать
        this.inputs = document.querySelectorAll('input'),
        // сообщения при отправке данных формы на сервер
        this.message = {
            loading: 'Отправка данных формы...',
            success: 'Спасибо! Скоро мы с вами свяжемся',
            failure: 'Что-то пошло не так...'
        };
        // путь к обработчику данных на сервере
        this.handler = 'assets/question.php';
    }

    async postData(url, data) {
        let res = await fetch(url, {
            method: 'POST',
            body: data
        });
        return await res.text();
    }

    // очищает все поля input всех форм на странице
    clearInputs() {
        this.inputs.forEach(item => {
            item.value = '';
        });
    };

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

        // функция очищает и форматирует ввод номера телефона в поле
        // input, вызывается при событиях blur, focus, input
        function phoneMask(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 = '';
                }
            }
        }
    }

    checkAllEmais() {
        const emailInputs = document.querySelectorAll('[type="email"]');

        emailInputs.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(/[-_@.0-9a-z]/i) === -1) {
                    event.preventDefault();
                }
            });
        });
    }

    init() {
        this.initPhoneMask();
        this.checkAllEmais();

        this.forms.forEach(form => {
            form.addEventListener('submit', (event) => {
                event.preventDefault();
                // создаем div-блок, в котором показываем сообщение
                let statusMessage = document.createElement('div');
                statusMessage.style.cssText = `
                    margin: 15px 0;
                    color: #f99;
                `;
                statusMessage.textContent = this.message.loading;
                form.before(statusMessage);
                // получаем данные формы для отправки на сервер
                const formData = new FormData(form);
                // отправляем данные формы обработчику на сервере
                this.postData(this.handler, formData)
                    .then(response => { // данные формы отправлены успешно
                        // console.log(response);
                        statusMessage.textContent = this.message.success;
                    })
                    .catch(() => statusMessage.textContent = this.message.failure) // ошибка
                    .finally(() => { // скрыть сообщение, очистить все формы
                        this.clearInputs();
                        setTimeout(() => {
                            statusMessage.remove();
                        }, 5000);
                    });
            });
        });
    }
}

Старница modules.html

В проекте есть две страницы index.html и modules.html, каждая страница представляет из себя набор блоков. Страница modules.html должна быть реализована в виде большого слайдера, где каждый блок является как бы «страницей» и эти «страницы» можно листать.

<body>
    <!-- слайдер «страниц» -->
    <div class="moduleapp">
        <!-- отдельные «страницы» -->
        <div class="module" id="1">.....</div>
        <div class="module" id="2">.....</div>
        <div class="module" id="3">.....</div>
        <div class="module" id="4">.....</div>
        <div class="module" id="5">.....</div>
        <div class="module" id="6">.....</div>
        <div class="module" id="7">.....</div>
        <div class="module" id="8">.....</div>
    </div>
    <div class="overlay">.....</div>
    <script src="script.js"></script>
</body>

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

import MainSlider from './modules/slider/MainSlider';
import Slider from './modules/slider/Slider';
/* ..... */

window.addEventListener('DOMContentLoaded', () => {
    // для страницы index.html
    new MainSlider({
        container: 'body > .page',
        next: '.sidecontrol__controls > .next'
    }).render();
    // для страницы modules.html
    new Slider({
        container: 'body > .moduleapp',
        next: '.sidecontrol__controls > .next'
    }).render();
    /* ..... */
});

Так что пришлось переписывать код modules/slider/Slider.js, modules/slider/MainSlider.js, modules/slider/MiniSlider.js.

export default class Slider {

    constructor({container, next, prev} = {}) {
        this.error = false;

        this.container = document.querySelector(container); // контейнер слайдов
        if (this.container === null) {
            this.error = true;
            return;
        }
        this.slides = this.container.children; // все слайды слайдера
        if (this.slides.length === 0) {
            this.error = true;
            return;
        }

        this.next = document.querySelectorAll(next); // кнопки вперед
        this.prev = document.querySelectorAll(prev); // кнопки назад
        this.slideIndex = 0; // индекс текущего слайда, начиная с нуля
    }

    // показывает слайд с индексом slideIndex
    showSlide() {
        /* ..... */
    }

    // смещает указатель текущего слайда на число number; число может
    // быть как положительным (вперед), так и отрицательным (назад)
    shiftSlider(number = 1) {
        /* ..... */
    }

    // выполняет всю работу по подготовке слайдера к работе
    // и показывает первый слайд
    render() {
        if (this.error) return;
        /* ..... */
    }
}
import Slider from './Slider';

export default class MainSlider extends Slider {

    constructor({container, next}) {
        super({container: container, next: next});
        this.logo = document.querySelector('.sidecontrol > a');
        if (this.logo === null) this.error = true;
        this.showHanson();
    }

    // скрывает блок с css-классом hanson, который расположен на третьем слайде
    // и показывает его через три секунды после перехода к третьему слайду
    showHanson() {
        /* ..... */
    }

    render() {
        super.render();
        if (this.error) return;
        /* ..... */
    }
}
import Slider from './Slider';

export default class MiniSlider extends Slider {

    constructor({container, next, prev, autoplay, current}) {
        super({container: container, next: next, prev: prev});
        this.autoplay = autoplay ? true : false;
        this.playing = undefined;
        try {
            this.wrapper = this.container.parentNode;
        } catch (e) {
            this.error = true;
        }
        this.current = current;
    }

    // выполняет всю работу по подготовке слайдера к работе
    render() {
        if (this.error) return;
        /* ..... */
    }
    /* ..... */
}

На странице modules.html листать «страницы» можно не только с помощью кнопки в левом нижнем углу, но и с помощью стрелок «вперед» и «назад» внизу страницы. Но мы с самого начала предусмотрели, что кнопок «вперед» и «назад» может быть несколько, так что нужно только добавить селекторы.

import MainSlider from './modules/slider/MainSlider';
import Slider from './modules/slider/Slider';
/* ..... */

window.addEventListener('DOMContentLoaded', () => {
    // для страницы index.html
    new MainSlider({
        container: 'body > .page',
        next: '.sidecontrol__controls > .next'
    }).render();
    // для страницы modules.html
    new Slider({
        container: 'body > .moduleapp',
        next: '.sidecontrol__controls > .next, .nextmodule',
        prev: '.prevmodule'
    }).render();
    /* ..... */
});

Видео в модальном окне

На всех «страницах» modules.html есть две кнопки для просмотра видео. Но второе видео можно открыть только после просмотра первого. Сейчас все видео доступны для просмотра в модальном окне, потому что на странице modules.html отработал js-код плеера, который мы написали для страницы index.html.

<div class="module__video">
    <!-- первое видео доступно для просмотра изначально -->
    <div class="module__video-item module__video-item_1">
        <div class="video__title">Show up</div>
        <div data-url="ZpCluchEflg" class="play">
            <div class="play__circle">
                <svg viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg">
                    <path d="M14 8L0 16V0L14 8Z" fill="#6D53AF"/>
                </svg>
            </div>
            <div class="play__text">play video</div>
        </div>
    </div>
    <!-- второе видео доступно после просмотра первого -->
    <div class="module__video-item module__video-item_2">
        <div class="video__title">Evolve</div>
        <div data-url="jrTMMG0zJyI" class="play">
            <div class="play__circle closed">
                <svg class="lock" width="21" height="27" viewBox="0 0 21 27" fill="none" xmlns="http://www.w3.org/2000/svg">
                    <g opacity="0.24">
                        <path fill-rule="evenodd" clip-rule="evenodd" d="..." fill="black"/>
                    </g>
                </svg>
            </div>
            <div class="play__text attention">
                Please watch the first video before
            </div>
        </div>
    </div>
    ..........
</div>

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

Чтобы не запутывать код класса для запуска просмотра видео, создадим еще один класс SecondVideo, который будет расширять VideoPlayer. Класс VideoPlayer будет отвечать за показ видео на странице index.html, а класс SecondVideo — за показ видео на странице modules.html

export default class VideoPlayer {

    constructor(triggers, overlay) {
        this.triggers = document.querySelectorAll(triggers);
        this.overlay = document.querySelector(overlay);
        this.close = this.overlay.querySelector('.close');
        this.player = undefined;
        this.options = undefined; // опции плеера
    }

    // обработка события клика для всех кнопок запуска видео
    bindTriggers() {
        this.triggers.forEach(item => {
            item.addEventListener('click', () => {
                this.handlePlayClick(item);
            });
        });
    }

    // обработка события клика для одной кнопки запуска видео
    handlePlayClick(item) {
        this.overlay.style.display = 'flex';
        // если плеер уже был создан ранее с другим видео, нужно удалить
        // iframe#frame и восстановить div#frame, чтобы создать новый
        if (this.player) {
            this.player.destroy();
        }
        this.createPlayer(item.dataset.url);
    }

    // устанавливаем настройки плеера перед его созданием
    setPlayerOptions() {
        this.options = {
            height: '100%',
            width: '100%'
        }
    }

    // создаем проигрыватель с использованием YouTube Player API
    createPlayer(id) {
        this.setPlayerOptions();
        this.options.videoId = id;
        this.player = new YT.Player('frame', this.options);
    }

    // при клике на крестик убираем overlay и останавливаем видео
    bindClose() {
        this.close.addEventListener('click', () => {
            this.overlay.style.display = 'none';
            this.player.stopVideo();
        });
    }

    // инициализация проигрывателя, подключение API и обработчики событий
    init() {
        // подключаем YouTube Player API
        const tag = document.createElement('script');
        tag.src = 'https://www.youtube.com/iframe_api';
        const firstScriptTag = document.getElementsByTagName('script')[0];
        firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
        // обработчики события клика для всех кнопок запуска видео
        this.bindTriggers();
        // обработчик события клика на кнопку крестика (закрыть)
        this.bindClose();
    }
}
import VideoPlayer from './VideoPlayer';

export default class SecondPlayer extends VideoPlayer {
    constructor(triggers, overlay) {
        super(triggers, overlay);
    }

    // обработка события клика для всех кнопок запуска видео
    bindTriggers() {
        this.triggers.forEach(item => {
            item.addEventListener('click', () => {
                // кнопка запуска второго видео не будет ничего делать;
                // но после просмотра первого видео, мы убираем css-класс
                // closed, так что условие ниже уже не сработает
                if (item.querySelector('.play__circle.closed')) {
                    return;
                }
                // нажатую кнопку запуска первого видео сохраняем, чтобы
                // по аналогии сделать кнопку запуска второго видео
                this.playButton = item;

                this.handlePlayClick(item);
            });
        });
    }

    // устанавливаем настройки плеера перед его созданием
    setPlayerOptions() {
        super.setPlayerOptions();
        this.options.events = {
            'onStateChange': this.onPlayerStateChange.bind(this)
        };
    }

    onPlayerStateChange(event) {
        // если первое видео просмотрено до конца
        if (event.data === YT.PlayerState.ENDED) {
            const secondVideo = this.playButton.closest('.module__video-item').nextElementSibling;
            if (secondVideo) { // если есть второе видео после первого
               /*
                * 1. Убрать css-класс .closed у кнопки запуска видео
                * 2. Заменить svg-иконку внутри кнопки запуска видео
                * 3. Убрать текст Please watch the first video before
                * 4. Изменить стили блока видео (filter и opacity)
                * Здесь самый важный момент, что мы убираем css-класс
                * closed, из-за которого второе видео не запускается
                */
                secondVideo.querySelector('.play__circle').classList.remove('closed');
                secondVideo.querySelector('svg').remove();
                const playIcon = this.playButton.querySelector('svg').cloneNode(true);
                secondVideo.querySelector('.play__circle').appendChild(playIcon);
                secondVideo.querySelector('.play__text').textContent = 'play video';
                secondVideo.querySelector('.play__text').classList.remove('attention');
                secondVideo.style.cssText = `
                    opacity: 1;
                    filter: none;
                `;
            }
        }
    }
}
/* ..... */
import VideoPlayer from './modules/VideoPlayer';
import SecondPlayer from './modules/SecondPlayer';
/* ..... */

window.addEventListener('DOMContentLoaded', () => {
    /* ..... */
    new VideoPlayer('.page .play', '.overlay').init();
    new SecondPlayer('.moduleapp .play', '.overlay').init();
    /* ..... */
});

Показать скрытый текст

На каждой «странице» есть скрытый блок, который надо показать при клике на иконку плюсика.

<div class="module__info-show">
    <div class="show">How are you showing up?</div>
    <div class="plus">
        <div class="plus__content">
            <svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
                <path d="..." fill="white"/>
                <path d="..." fill="white"/>
            </svg>
        </div>
    </div>
</div>
<div class="msg">
    Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
    magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
    consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla paria.
    Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</div>
export default class ShowInfo {
    constructor (triggers) {
        this.triggers = document.querySelectorAll(triggers);
    }

    init() {
        this.triggers.forEach(item => {
            item.addEventListener('click', () => {
                item.closest('.module__info-show').nextElementSibling.style.display = 'block';
            });
        });
    }
}
/* ..... */
import ShowInfo from './modules/ShowInfo';

window.addEventListener('DOMContentLoaded', () => {
    /* ..... */
    new ShowInfo('.plus__content').init();
});

Скачивание файлов

На каждой «странице» modules.html есть блок «Download PDF», если по нему кликнуть — должен скачиваться pdf-файл. Это легко сделать, если бы этот блок был ссылкой — для этого достаточно добавить ссылке атрибут download. Но мы работаем с уже готовой версткой — и надо сделать это с помощью js-кода, без вмешательства в html-код. Давайте для упрощения будем считать, что pdf-файл всего один — хотя в реальной ситуации их было бы несколько.

<div class="download">
    <div class="download__text">Download PDF</div>
    <svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg">
        <path fill-rule="evenodd" clip-rule="evenodd" d="..." fill="#9EC73D"/>
    </svg>
</div>
export default class Download {
    constructor(triggers) {
        this.triggers = document.querySelectorAll(triggers);
        this.path = 'assets/document.pdf';
    }

    download(path) {
        // создаем ссылку, добавляем атрибуты href и download
        const link = document.createElement('a');
        link.setAttribute('href', path);
        link.setAttribute('download', 'NiceName');
        link.style.display = 'none';
        document.body.append(link);
        // вызываем событие клика, чтобы начать скачивание pdf
        link.click();
        // больше ссылка не нужна, поэтому удаляем ее из DOM
        link.remove();
    }

    init() {
        this.triggers.forEach(item => {
            item.addEventListener('click', () => {
                this.download(this.path);
            });
        });
    }
}
/* ..... */
import Download from './modules/Download';

window.addEventListener('DOMContentLoaded', () => {
    /* ..... */
    new Download('.download').init();
});

Поиск: 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.