diff --git a/recipes/index.md b/recipes/index.md index 3ed27ff623..08870438a1 100644 --- a/recipes/index.md +++ b/recipes/index.md @@ -15,6 +15,7 @@ groups: - lazy-load-with-preview - validation - masonry + - slider - checkbox-radio-style - name: 'Работа с GitHub' items: diff --git a/recipes/slider/demos/slider-demo/images/arrow.svg b/recipes/slider/demos/slider-demo/images/arrow.svg new file mode 100644 index 0000000000..b05551b08a --- /dev/null +++ b/recipes/slider/demos/slider-demo/images/arrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/recipes/slider/demos/slider-demo/images/flowers.jpg b/recipes/slider/demos/slider-demo/images/flowers.jpg new file mode 100644 index 0000000000..a2c9d794d0 Binary files /dev/null and b/recipes/slider/demos/slider-demo/images/flowers.jpg differ diff --git a/recipes/slider/demos/slider-demo/images/lilac.jpg b/recipes/slider/demos/slider-demo/images/lilac.jpg new file mode 100644 index 0000000000..5a59929d42 Binary files /dev/null and b/recipes/slider/demos/slider-demo/images/lilac.jpg differ diff --git a/recipes/slider/demos/slider-demo/images/scarlet.jpg b/recipes/slider/demos/slider-demo/images/scarlet.jpg new file mode 100644 index 0000000000..7a556f9484 Binary files /dev/null and b/recipes/slider/demos/slider-demo/images/scarlet.jpg differ diff --git a/recipes/slider/demos/slider-demo/images/summer.jpg b/recipes/slider/demos/slider-demo/images/summer.jpg new file mode 100644 index 0000000000..9b7717e446 Binary files /dev/null and b/recipes/slider/demos/slider-demo/images/summer.jpg differ diff --git a/recipes/slider/demos/slider-demo/index.html b/recipes/slider/demos/slider-demo/index.html new file mode 100644 index 0000000000..ab8f75e336 --- /dev/null +++ b/recipes/slider/demos/slider-demo/index.html @@ -0,0 +1,270 @@ + + + + + Пример слайдера — Слайдер — Дока + + + + + + + + + +
+
+ + + + + + + +
+ +
+ + + + +
+
+ + + + + diff --git a/recipes/slider/index.md b/recipes/slider/index.md new file mode 100644 index 0000000000..d4eab804da --- /dev/null +++ b/recipes/slider/index.md @@ -0,0 +1,748 @@ +--- +title: "Слайдер" +description: "Пишем доступный слайдер на HTML, CSS и JavaScript." +authors: + - annabaraulina +contributors: + - tatianafokina + - skorobaeus +keywords: + - баннеры + - перелистывающиеся баннеры + - css-слайдер +related: + - a11y/aria-labelledby + - html/button + - recipes/popup +tags: + - article +--- + +## Задача + +Слайдер или слайд-шоу — распространённый дизайнерский паттерн на сайтах, особенно на лендингах. Слайдер состоит из нескольких слайдов, обычно с изображениями или с видео-контентом. Между слайдами можно переключаться, также бывают варианты с автоматической прокруткой и кнопками для управления анимацией. + + + +Слайдер — неоднозначный элемент. С одной стороны, он экономит место на странице, вмещает больше контента за счёт прокрутки, а ещё привлекает внимание. С другой — его сложно сделать удобным и доступным для всех пользователей. + +В этом рецепте расскажем, как создать доступный слайдер, в котором подумаем про дизайн и учтём семантику. + +## Готовое решение + +Для начала создадим HTML-разметку со всеми нужными элементами — общим контейнером, кнопками для переключения слайдов и самими слайдами с картинками и заголовками. + +```html +
+
+ + + + + + + +
+ +
+ + + + +
+
+``` + +Для стилизации слайдера используем такие [CSS-правила](/css/css-rule/): + +```css +.controls { + margin-block-end: 20px; +} + +.button { + cursor: pointer; + user-select: none; +} + +.button-radio { + background-color: transparent; + margin: 0; + padding: 0; + inline-size: 15px; + block-size: 15px; + border-radius: 50%; + border: 1px solid #FFFFFF; +} + +.button-radio + .button-radio { + margin-inline-start: 12px; +} + +.button-radio.active { + background-color: #C56FFF; + pointer-events: none; +} + +.button-radio:focus-visible { + outline: 3px solid white; + outline-offset: -1px; +} + +.button-prev, +.button-next { + position: absolute; + inset-block-start: 50%; + transform: translateY(-50%); + border: none; + inline-size: 30px; + block-size: 42px; + background-color: transparent; + background-image: url(./images/arrow.svg); + background-repeat: no-repeat; + background-size: contain; +} + +.button-prev[aria-disabled="true"], +.button-next[aria-disabled="true"] { + opacity: 0.5; + pointer-events: none; +} + +.button-prev { + inset-inline-start: -50px; +} + +.button-next { + inset-inline-end: -50px; + transform: translateY(-50%) rotateY(180deg); + transform-origin: center; +} + +.slide-img { + display: block; + inline-size: 100%; + block-size: 225px; + max-block-size: 225px; + object-fit: cover; +} + +.slider { + display: flex; + flex-direction: column; + align-items: center; + max-inline-size: 600px; + inline-size: 100%; + position: relative; +} + +.slides { + inline-size: 100%; +} + +.slide { + display: none; + text-align: center; +} + +.slide--active { + display: block; +} + +@media (max-width: 768px) { + .slider { + max-inline-size: 260px; + } + + .slide-img { + block-size: 400px; + } + + .button-prev { + inset-inline-start: -40px; + } + + .button-next { + inset-inline-end: -40px; + } +} +``` + +Для прокрутки и перемещения по слайдам с помощью кнопок и клавиатурных клавиш со стрелками используем JavaScript: + +```javascript +document.addEventListener('DOMContentLoaded', function () { + const slider = document.querySelector('.slider') + const slides = slider.querySelectorAll('.slide') + const activeSlides = 'slide--active' + const slideCount = slides.length + const controlButtons = slider.querySelectorAll('.button-radio') + const prevButton = slider.querySelector('.button-prev') + const nextButton = slider.querySelector('.button-next') + const activeButton = 'active' + const inactiveButton = 'aria-disabled' + const currentButton = 'aria-current' + let currentSlide = 0 + + function updateSlider() { + slides.forEach((slide, index) => { + if(index === currentSlide) { + slide.classList.add(activeSlides) + } else { + slide.classList.remove(activeSlides) + } + }) + + controlButtons.forEach((button, index) => { + if (index === currentSlide) { + button.classList.add(activeButton) + button.setAttribute(currentButton, true) + } else { + button.classList.remove(activeButton) + button.removeAttribute(currentButton, true) + } + + prevButton.setAttribute(inactiveButton, currentSlide === 0) + nextButton.setAttribute(inactiveButton, currentSlide === slideCount - 1) + }) + } + + controlButtons.forEach((button, index) => { + button.addEventListener('click', () => { + if (index < slideCount) { + currentSlide = index + updateSlider() + } + }) + }) + + prevButton.addEventListener('click', () => { + if (currentSlide > 0) { + currentSlide-- + updateSlider() + } + }) + + nextButton.addEventListener('click', () => { + if (currentSlide < slideCount - 1) { + currentSlide++ + updateSlider() + } + }) + + slider.addEventListener('keydown', function (event) { + if (event.key === 'ArrowLeft' && currentSlide > 0) { + currentSlide-- + updateSlider() + } else if (event.key === 'ArrowRight' && currentSlide < slideCount - 1) { + currentSlide++ + updateSlider() + } + }) + + updateSlider() +}) +``` + + + +## Разбор решения + +### Дизайн + +С точки зрения дизайна важно учесть особенности восприятия пользователей, которые чувствительны к движущемуся контенту. Это проблема для людей с когнитивными, моторными или зрительными особенностями. + +Слайдер должен быть под полным контролем пользователей, поэтому мы не будем добавлять автопрокрутку. + +### HTML + +Вместо автоматической прокрутки добавляем кнопки для последовательного переключения слайдов. Важно, чтобы интерактивные элементы контрастировали с фоном слайдера. Так как тип кнопок по умолчанию `submit`, установим руками другой тип `button`. Мы не отправляем данные на сервер, и нам не нужна перезагрузка страницы. + +```html + +``` + +Не ограничивайтесь одними стрелками для предыдущего и следующего слайда и предоставляйте пользователю альтернативный способ навигации. С помощью специальных кнопок, которые называют _точками прогресса (progress dots)_, также показываем пользователю текущее положение в группе слайдов. + +```html + + + +``` + +Расположение элементов в [DOM](/js/dom/) (Document Object Model) влияет на порядок, в котором пользователи клавиатуры перемещаются по странице. По этой причине располагаем элементы навигации перед слайдером, а не после него. В этом случае пользователю не нужно будет возвращаться назад, чтобы прокрутить слайды: + +```html +
+ +
+ +
+ +
+``` + +Содержимое слайдов группируем в одном [`
`](/html/div/). + +```html + +``` + +### Семантика и ARIA-разметка + +В первую очередь используем семантические теги [` +``` + +С кнопками для пролистывания слайдов в одном направлении всё ещё проще: «Следующий» для кнопки вперёд и «Предыдущий» для кнопки назад. + +```html + + +``` + +Чтобы не делать неактивными кнопки-точки и передать, что пользователь вспомогательной технологии выбрал конкретный по счёту слайд, навесим атрибут [`aria-current`](/a11y/aria-current/). Он сообщает, в каком месте слайдера сейчас находится пользователь скринридера. Так как по умолчанию слайдер показывает первый слайд, на старте добавим атрибут к первой кнопке. + +```html + +``` + +Теперь сложный и дискуссионный момент. Кнопка для перемещения к предыдущему слайду неактивна, когда мы на первом слайде. Кнопка «Следующий» перестаёт работать, когда выбран последний слайд. Чтобы передать это не только визуально через CSS, но и в HTML, используем атрибут [`aria-disabled`](/a11y/aria-disabled/). Почему? ARIA-атрибут для неактивных кнопок остаётся доступен для вспомогательных технологий и, при этом, на них всё ещё можно установить клавиатурный фокус. HTML-атрибут [`disabled`](/html/disabled/) полностью недоступен для всех. Это иногда приводит к тому, что пользователи скринридеров неожиданно «теряют» кнопки на странице. Чтобы кнопки не были неактивными по умолчанию, добавляем атрибут через JavaScript. Если что-то пошло не так со скриптом, интерактивные элементы всегда будут активными. + +Не забудем описать картинки в слайдах. В этом случае они не декоративные и важны для понимания. Без этого сложно представить, как именно выглядят наши паттерны. + +```html +Абстрактные цветы розовых, синих,
+  малиновых и оранжевых оттенков на зелёном фоне. +``` + +### CSS + +Сначала задаём стили для всего слайдера. Делаем его флекс-контейнером, выстраиваем элементы в колонку, центрируем через `align-items: center;` и задаём максимальную ширину `max-width`. Так как у нас есть кнопки со стрелками в право и левой части слайдера, добавляем всему контейнеру `position: relative`. + +```css +.slider { + display: flex; + flex-direction: column; + align-items: center; + max-inline-size: 600px; + inline-size: 100%; + position: relative; +} +``` + +Сами слайды и картинки в них растягиваем на всю доступную ширину, а картинке задаём [`object-fit`](/css/object-fit/), чтобы пропорции картинки не искажались. + +```css +.slides { + inline-size: 100%; +} + +.slide { + text-align: center; +} + +.slide-img { + width: 100%; + height: 225px; + object-fit: cover; +} +``` + +Кнопки-точки скругляем с помощью [`border-radius`](/css/border-radius/), а с помощью изменения цвета фона показываем активную кнопку. Также не забываем и про стили для клавиатурного фокуса [`:focus-visible`](/css/focus-visible/). + +```css +.button_type_radio { + background-color: transparent; + margin: 0; + padding: 0; + inline-size: 15px; + block-size: 15px; + border-radius: 50%; + border: 1px solid #868A8F; +} + +.button_type_radio.active { + background-color: #53d67b; +} + +.button_type_radio:focus-visible { + outline: 3px solid white; + outline-offset: -1px; +} +``` + +Кнопки для предыдущего и следующего слайда позиционируем с помощью [`position: absolute`](/css/position/) и отрицательных значений для [`inset-inline-start`](/css/inset/) и [`inset-inline-end`](/css/inset/) соответственно. Стрелки добавляем через свойство [`background-image`](/css/background-image/). + +```css +.prev-button, +.next-button { + position: absolute; + inset-block-start: 50%; + transform: translateY(-50%); + border: none; + inline-size: 30px; + block-size: 42px; + background-color: transparent; + background-image: url(arrow.svg); + background-repeat: no-repeat; + background-size: contain; + cursor: pointer; +} + +.button-prev { + inset-inline-start: -40px; +} + +.button-next { + inset-inline-end: -40px; + transform: translateY(-50%) rotateY(180deg); + transform-origin: center; +} +``` + +Находим неактивные кнопки со стрелками по атрибуту `aria-disabled="true"`, добавляем для них небольшую прозрачность через `opacity` и добавляем для пользователей мышки свойство `pointer-events: none`. + +```css +.button-prev[aria-disabled="true"], +.button-next[aria-disabled="true"] { + opacity: 0.5; + pointer-events: none; +} +``` + +Слайдер можно анимировать на свой вкус. Если используете анимацию, оберните соответствующие правила в директиву [`@media`](/css/media/) со значением [`prefers-reduced-motion: no-preference`](/a11y/prefers-reduced-motion/). Так пользователи, которые отключили анимацию у себя в системе, не столкнутся с анимацией в слайдере. + +### JavaScript + +Для начала будем слушать событие окончания парсинга страницы [`DOMContentLoaded`](/js/event-domcontentloaded/), прежде чем добавлять все нужные атрибуты в слайдер. + +```javascript +document.addEventListener('DOMContentLoaded', function () { + // Тело скрипта +}) +``` + +Теперь скрываем все слайды, кроме текущего. По умолчанию это первый слайд. + +```javascript +const slider = document.querySelector('.slider') +const slides = slider.querySelectorAll('.slide') +const activeSlides = 'slide--active' +let currentSlide = 0 + +slides.forEach((slide, index) => { + if(index === currentSlide) { + slide.classList.add(activeSlides) + } else { + slide.classList.remove(activeSlides) + } +}) +``` + +После делаем активными и показываем только те элементы, которые соответствуют текущему слайду. На этом шаге переключаем значение атрибута `aria-current` с `true` на `false` и добавляем и удаляем с кнопок `aria-disabled`. + +```javascript +const slideCount = slides.length +const controlButtons = slider.querySelectorAll('.button-radio') +const prevButton = slider.querySelector('.button-prev') +const nextButton = slider.querySelector('.button-next') +const activeButton = 'active' +const inactiveButton = 'aria-disabled' +const currentButton = 'aria-current' + +let currentSlide = 0 + +function updateSlider() { + controlButtons.forEach((button, index) => { + if (index === currentSlide) { + button.classList.add(activeButton) + button.setAttribute(currentButton, true) + } else { + button.classList.remove(activeButton) + button.removeAttribute(currentButton, true) + } + + prevButton.setAttribute(inactiveButton, currentSlide === 0) + nextButton.setAttribute(inactiveButton, currentSlide === slideCount - 1) + }) +} +``` + +Чтобы кнопки начали переключать слайды, будем слушать событие [`click`](/js/element-click/) на них. Пройдёмся по массиву кнопок-точек. Проверим, что кнопке соответствует нужный слайд, а потом перейдём к слайду с тем же порядковым номером, что у кнопки. + +```javascript +const slideCount = slides.length +const controlButtons = slider.querySelectorAll('.button-radio') + +controlButtons.forEach((button, index) => { + button.addEventListener('click', () => { + if (index < slideCount) { + currentSlide = index + updateSlider() + } + }) +}) +``` + +А так поступим с кнопками для переключения слайдов в одном направлении: + +```javascript +const prevButton = slider.querySelector('.button-prev') +const nextButton = slider.querySelector('.button-next') + +prevButton.addEventListener('click', () => { + if (currentSlide > 0) { + currentSlide-- + updateSlider() + } +}) + +nextButton.addEventListener('click', () => { + if (currentSlide < slideCount - 1) { + currentSlide++ + updateSlider() + } +}) +``` + +Наконец, дополнительно предусмотрим перелистывание слайдов клавиатурными стрелками. Для этого слушаем событие [`keydown`](/js/element-keydown/) для клавиш `ArrowLeft` и `ArrowRight`. + +```javascript +slider.addEventListener('keydown', function (event) { + if (event.key === 'ArrowLeft' && currentSlide > 0) { + currentSlide-- + updateSlider() + } else if (event.key === 'ArrowRight' && currentSlide < slideCount - 1) { + currentSlide++ + updateSlider() + } +}) +```