Практическая работа: создание компонента Stories

В этой практической работе вы узнаете, как создать веб-приложение, похожее на Instagram Stories. Мы будем создавать компонент по ходу дела, начиная с HTML, затем CSS, затем JavaScript.

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

Настройка

  1. Нажмите Создать ремикс, чтобы сделать проект редактируемым.
  2. Открыть чат "app/index.html".

HTML

Я всегда стараюсь использовать семантическую разметку HTML. Поскольку у каждого друга может быть любое количество историй, я решил использовать элемент <section> для каждого друга и элемент <article> для каждой истории. Но давайте начнем с самого начала. Сначала нам нужен контейнер для компонента историй.

Добавьте элемент <div> в <body>:

<div class="stories">

</div>

Добавьте несколько элементов <section>, чтобы представить друзей:

<div class="stories">
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
</div>

Добавьте элементы <article>, которые будут представлять сюжеты:

<div class="stories">
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/480/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/840);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/482/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/843);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/844);"></article>
  </section>
</div>
  • Мы используем сервис изображений (picsum.com), чтобы создавать прототипы историй.
  • Атрибут style в каждом элементе <article> – это часть метода загрузки плейсхолдера, о котором вы узнаете больше в следующем разделе.

CSS

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

.stories

Для контейнера <div class="stories"> мы хотим использовать контейнер с горизонтальной прокруткой. Для этого мы можем:

  • Как сделать контейнер сеткой
  • Настройка каждого дочернего элемента для заполнения дорожки строки
  • Ширина каждого дочернего элемента равна ширине области просмотра мобильного устройства.

Сетка будет размещать новые столбцы шириной 100vw справа от предыдущего, пока не разместит все элементы HTML в вашей разметке.

Окно Chrome и Инструменты разработчика с сеткой, показывающей макет на всю ширину экрана
Инструменты разработчика Chrome, показывающие переполнение столбца сетки, из-за которого появляется горизонтальная полоса прокрутки.

Добавьте в конец файла app/css/index.css следующий код CSS:

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
}

Теперь, когда у нас есть контент, выходящий за пределы области просмотра, пришло время указать контейнеру, как с ним обращаться. Добавьте выделенные строки кода в набор правил .stories:

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior: contain;
  touch-action: pan-x;
}

Нам нужна горизонтальная прокрутка, поэтому мы зададим для overflow-x значение auto. Когда пользователь прокручивает страницу, мы хотим, чтобы компонент плавно переходил к следующей истории, поэтому мы будем использовать scroll-snap-type: x mandatory. Подробнее об этом читайте в разделах Точки привязки прокрутки CSS и overscroll-behavior в моем сообщении в блоге.

Чтобы привязка прокрутки работала, ее нужно настроить и для родительского контейнера, и для дочерних элементов. Добавьте в конец файла app/css/index.css следующий код:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

Ваше приложение пока не работает, но на видео ниже показано, что происходит, когда scroll-snap-type включено и отключено. Если включить эту функцию, при горизонтальной прокрутке будет открываться следующая история. Если правило отключено, браузер использует прокрутку по умолчанию.

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

.user

Давайте создадим макет в разделе .user, который упорядочит эти дочерние элементы. Чтобы решить эту проблему, мы воспользуемся удобным приемом наложения. По сути, мы создаем сетку 1x1, в которой строка и столбец имеют один и тот же псевдоним сетки [story], и каждый элемент сетки истории будет пытаться занять это пространство, в результате чего получится стек.

Добавьте выделенный код в набор правил .user:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  display: grid;
  grid: [story] 1fr / [story] 1fr;
}

Добавьте следующий набор правил в конец файла app/css/index.css:

.story {
  grid-area: story;
}

Теперь, без абсолютного позиционирования, плавающих элементов или других директив макета, которые выводят элемент из потока, мы все ещё находимся в потоке. Кроме того, это почти не код, посмотрите на это! Подробнее об этом рассказывается в видео и записи блога.

.story

Теперь нужно настроить стиль самого элемента истории.

Ранее мы упоминали, что атрибут style каждого элемента <article> является частью метода загрузки плейсхолдера:

<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>

Мы будем использовать свойство CSS background-image, которое позволяет указать несколько фоновых изображений. Мы можем расположить их в таком порядке, чтобы изображение пользователя было сверху и автоматически появлялось после завершения загрузки. Для этого мы поместим URL изображения в настраиваемое свойство (--bg) и используем его в CSS, чтобы наложить на плейсхолдер.

Сначала обновим набор правил .story, чтобы заменить градиент фоновым изображением после его загрузки. Добавьте выделенный код в набор правил .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}

Если задать для свойства background-size значение cover, в области просмотра не будет пустого пространства, поскольку изображение заполнит ее целиком. Определение двух фоновых изображений позволяет нам использовать удобный веб-прием CSS, который называется загрузкой надгробия:

  • Фоновое изображение 1 (var(--bg)) – это URL, который мы передали в HTML-коде.
  • Фоновое изображение 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) – это градиент, который показывается во время загрузки URL)

Когда изображение будет скачано, CSS автоматически заменит им градиент.

Теперь добавим код CSS, чтобы отключить некоторые функции и ускорить работу браузера. Добавьте выделенный код в набор правил .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;
}
  • user-select: none предотвращает случайное выделение текста пользователями.
  • touch-action: manipulation сообщает браузеру, что эти взаимодействия следует рассматривать как события касания, что освобождает браузер от попыток решить, нажимаете ли вы на URL-адрес или нет.

Наконец, добавим немного CSS, чтобы анимировать переход между историями. Добавьте выделенный код в набор правил .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;

  transition: opacity .3s cubic-bezier(0.4, 0.0, 1, 1);

  &.seen {
    opacity: 0;
    pointer-events: none;
  }
}

Класс .seen будет добавлен в историю, из которой нужно выйти. Я взял пользовательскую функцию плавности (cubic-bezier(0.4, 0.0, 1,1)) из руководства по плавности Material Design (прокрутите до раздела Ускоренная плавность).

Если вы внимательны, то, вероятно, заметили объявление pointer-events: none и сейчас ломаете голову над тем, что оно означает. Я бы сказал, что это единственный недостаток решения на данный момент. Это необходимо, поскольку элемент .seen.story будет находиться сверху и получать нажатия, даже если он невидим. Установив для свойства pointer-events значение none, мы превращаем стеклянную историю в окно и больше не крадем взаимодействия пользователей. Неплохой компромисс, и не слишком сложно управлять этим в нашем CSS прямо сейчас. Мы не жонглируем z-index. Я все ещё чувствую себя хорошо.

JavaScript

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

Настройка

Для начала давайте вычислим и сохраним как можно больше информации. Добавьте в файл app/js/index.js следующий код:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

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

Штат

Затем мы создаем небольшой объект с некоторым состоянием, соответствующим нашей логике. В данном случае нас интересует только текущая история. В нашей HTML-разметке мы можем получить к нему доступ, взяв первого друга и его последнюю историю. Добавьте выделенный код в файл app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

Слушатели

Теперь у нас достаточно логики, чтобы начать прослушивать пользовательские события и направлять их.

Мышь

Для начала добавим прослушивание события 'click' в контейнер историй. Добавьте выделенный код в app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

Если клик происходит не по элементу <article>, мы ничего не делаем. Если это статья, мы получаем горизонтальное положение мыши или пальца с помощью clientX. Мы ещё не реализовали navigateStories, но аргумент, который он принимает, указывает, в каком направлении нам нужно двигаться. Если позиция пользователя больше медианы, мы знаем, что нам нужно перейти к next, в противном случае – к prev (предыдущему).

Клавиатура

Теперь давайте послушаем нажатия клавиш. Если нажата стрелка вниз, мы переходим к next. Если это стрелка вверх, мы переходим к prev.

Добавьте выделенный код в app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

document.addEventListener('keydown', ({key}) => {
  if (key !== 'ArrowDown' || key !== 'ArrowUp')
    navigateStories(
      key === 'ArrowDown'
        ? 'next'
        : 'prev')
})

Навигация по историям

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

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

Эти переменные помогут нам ответить на такие вопросы, как "если пользователь смотрит историю X, означает ли "далее" переход к другой истории того же друга или к истории другого друга?" Я сделал это, используя древовидную структуру, которую мы построили, обращаясь к родителям и их детям.

Добавьте в конец файла app/js/index.js следующий код:

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling
}

Вот цель бизнес-логики, максимально приближенная к естественному языку:

  • Решите, что должно происходить при нажатии.
    • Если есть следующая или предыдущая история, показать ее.
    • Если это последняя или первая история друга, показывается история нового друга.
    • Если в этом направлении нет истории, ничего не происходит.
  • Добавить текущую историю в state

Добавьте выделенный код в функцию navigateStories:

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling

  if (direction === 'next') {
    if (lastItemInUserStory === story && !hasNextUserStory)
      return
    else if (lastItemInUserStory === story && hasNextUserStory) {
      state.current_story = story.parentElement.nextElementSibling.lastElementChild
      story.parentElement.nextElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.classList.add('seen')
      state.current_story = story.previousElementSibling
    }
  }
  else if(direction === 'prev') {
    if (firstItemInUserStory === story && !hasPrevUserStory)
      return
    else if (firstItemInUserStory === story && hasPrevUserStory) {
      state.current_story = story.parentElement.previousElementSibling.firstElementChild
      story.parentElement.previousElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.nextElementSibling.classList.remove('seen')
      state.current_story = story.nextElementSibling
    }
  }
}

Попробовать

  • Чтобы посмотреть сайт, нажмите View App (Посмотреть приложение), а затем – Fullscreen (Полноэкранный режим) полноэкранный режим.

Заключение

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