Atelier de programmation: créer un composant "Stories"

Dans cet atelier de programmation, vous apprendrez à créer une expérience semblable à Instagram Stories sur le Web. Nous allons créer le composant au fur et à mesure, en commençant par HTML, puis CSS et enfin JavaScript.

Consultez mon article de blog Créer un composant Stories pour en savoir plus sur les améliorations progressives apportées lors de la création de ce composant.

Configuration

  1. Cliquez sur Remix to Edit (Remixer pour modifier) pour rendre le projet modifiable.
  2. Ouvrez app/index.html.

HTML

J'essaie toujours d'utiliser le HTML sémantique. Comme chaque ami peut avoir un nombre quelconque d'histoires, j'ai pensé qu'il était judicieux d'utiliser un élément <section> pour chaque ami et un élément <article> pour chaque histoire. Commençons par le début. Nous avons d'abord besoin d'un conteneur pour notre composant d'histoires.

Ajoutez un élément <div> à votre <body> :

<div class="stories">

</div>

Ajoutez des éléments <section> pour représenter les amis :

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

Ajoutez des éléments <article> pour représenter les histoires :

<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>
  • Nous utilisons un service d'images (picsum.com) pour prototyper des histoires.
  • L'attribut style de chaque <article> fait partie d'une technique de chargement d'espace réservé, que vous découvrirez plus en détail dans la section suivante.

CSS

Nos contenus sont prêts à être stylisés. Transformons ces os en quelque chose avec lequel les gens voudront interagir. Aujourd'hui, nous allons travailler en mode mobile-first.

.stories

Pour notre conteneur <div class="stories">, nous voulons un conteneur à défilement horizontal. Pour ce faire :

  • Transformer le conteneur en grille
  • Définir chaque enfant pour qu'il remplisse la piste de ligne
  • Définir la largeur de chaque enfant sur la largeur d'une fenêtre d'affichage d'appareil mobile

Grid continuera de placer de nouvelles colonnes de 100vw pixels de large à droite de la précédente, jusqu'à ce qu'il ait placé tous les éléments HTML dans votre balisage.

Chrome et les outils pour les développeurs s&#39;ouvrent avec une grille montrant la mise en page en pleine largeur.
Outils pour les développeurs Chrome affichant un débordement de colonne de grille, créant un défilement horizontal.

Ajoutez le code CSS suivant en bas de app/css/index.css :

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

Maintenant que le contenu s'étend au-delà de la fenêtre d'affichage, il est temps d'indiquer au conteneur comment le gérer. Ajoutez les lignes de code en surbrillance à votre ensemble de règles .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;
}

Nous voulons un défilement horizontal. Nous allons donc définir overflow-x sur auto. Lorsque l'utilisateur fait défiler l'écran, nous voulons que le composant se repose doucement sur l'histoire suivante. Nous allons donc utiliser scroll-snap-type: x mandatory. Pour en savoir plus sur ce CSS, consultez les sections Points d'accrochage de défilement CSS et overscroll-behavior de mon article de blog.

Le conteneur parent et les enfants doivent accepter l'accrochage au défilement. Nous allons donc nous en occuper maintenant. Ajoutez le code suivant en bas de app/css/index.css :

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

Votre application ne fonctionne pas encore, mais la vidéo ci-dessous montre ce qui se passe lorsque scroll-snap-type est activé et désactivé. Lorsqu'il est activé, chaque défilement horizontal passe à l'article suivant. Si elle est désactivée, le navigateur utilise son comportement de défilement par défaut.

Vous pouvez ainsi parcourir la liste de vos amis, mais nous devons encore résoudre le problème des stories.

.user

Créons une mise en page dans la section .user qui organise ces éléments de story enfants. Nous allons utiliser une astuce d'empilement pratique pour résoudre ce problème. Nous créons essentiellement une grille 1x1 où la ligne et la colonne ont le même alias de grille [story], et chaque élément de la grille d'histoires va essayer de revendiquer cet espace, ce qui entraînera un empilement.

Ajoutez le code en surbrillance à votre ensemble de règles .user :

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

Ajoutez l'ensemble de règles suivant en bas de app/css/index.css :

.story {
  grid-area: story;
}

Maintenant, sans positionnement absolu, sans flottants ni autres directives de mise en page qui sortent un élément du flux, nous sommes toujours dans le flux. De plus, il n'y a presque pas de code. Regardez ! Ce point est abordé plus en détail dans la vidéo et l'article de blog.

.story

Il ne nous reste plus qu'à styliser l'élément d'histoire lui-même.

Nous avons mentionné précédemment que l'attribut style de chaque élément <article> fait partie d'une technique de chargement d'espace réservé :

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

Nous allons utiliser la propriété background-image de CSS, qui nous permet de spécifier plusieurs images de fond. Nous pouvons les placer dans un ordre tel que la photo de notre utilisateur se trouve en haut et s'affiche automatiquement une fois le chargement terminé. Pour ce faire, nous allons placer l'URL de notre image dans une propriété personnalisée (--bg) et l'utiliser dans notre CSS pour la superposer à l'espace réservé de chargement.

Commençons par mettre à jour l'ensemble de règles .story pour remplacer un dégradé par une image d'arrière-plan une fois qu'il est chargé. Ajoutez le code en surbrillance à votre ensemble de règles .story :

.story {
  grid-area: story;

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

Définir background-size sur cover garantit qu'il n'y a pas d'espace vide dans la fenêtre d'affichage, car notre image la remplira. Définir deux images de fond nous permet d'utiliser une astuce CSS appelée pierre tombale de chargement :

  • L'image de fond 1 (var(--bg)) correspond à l'URL que nous avons transmise en ligne dans le code HTML.
  • Image d'arrière-plan 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) est un dégradé à afficher pendant le chargement de l'URL)

Une fois l'image téléchargée, le CSS remplacera automatiquement le dégradé par l'image.

Nous allons ensuite ajouter du code CSS pour supprimer certains comportements, ce qui permettra au navigateur de fonctionner plus rapidement. Ajoutez le code en surbrillance à votre ensemble de règles .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 empêche les utilisateurs de sélectionner du texte par inadvertance.
  • touch-action: manipulation indique au navigateur que ces interactions doivent être traitées comme des événements tactiles, ce qui évite au navigateur d'essayer de déterminer si vous cliquez sur une URL ou non.

Enfin, ajoutons un peu de CSS pour animer la transition entre les histoires. Ajoutez le code mis en surbrillance à votre ensemble de règles .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;
  }
}

La classe .seen sera ajoutée à une story qui nécessite une sortie. J'ai obtenu la fonction d'interpolation personnalisée (cubic-bezier(0.4, 0.0, 1,1)) à partir du guide Easing de Material Design (faites défiler la page jusqu'à la section Accerlerated easing).

Si vous avez l'œil vif, vous avez probablement remarqué la déclaration pointer-events: none et vous vous demandez ce qu'elle signifie. Je dirais que c'est le seul inconvénient de la solution jusqu'à présent. Nous en avons besoin, car un élément .seen.story sera au-dessus et recevra les appuis, même s'il est invisible. En définissant pointer-events sur none, nous transformons l'histoire du verre en fenêtre et ne volons plus d'interactions utilisateur. Ce n'est pas un mauvais compromis, et ce n'est pas trop difficile à gérer dans notre CSS pour le moment. Nous ne jonglons pas avec z-index. Je suis toujours optimiste.

JavaScript

Les interactions d'un composant Stories sont assez simples pour l'utilisateur : appuyez à droite pour passer à la page suivante et à gauche pour revenir à la page précédente. Ce qui est simple pour les utilisateurs est souvent difficile pour les développeurs. Nous nous occuperons de la plupart des tâches.

Configuration

Pour commencer, calculons et stockons autant d'informations que possible. Ajoutez le code suivant à app/js/index.js :

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

Notre première ligne de JavaScript récupère et stocke une référence à notre élément racine HTML principal. La ligne suivante calcule le milieu de notre élément, ce qui nous permet de déterminer si un appui doit faire avancer ou reculer l'élément.

État

Nous allons ensuite créer un petit objet avec un état pertinent pour notre logique. Dans ce cas, nous ne nous intéressons qu'à l'histoire actuelle. Dans notre balisage HTML, nous pouvons y accéder en récupérant le premier ami et son histoire la plus récente. Ajoutez le code en surbrillance à votre fichier app/js/index.js :

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

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

Écouteurs

Nous avons maintenant suffisamment de logique pour commencer à écouter les événements utilisateur et à les diriger.

Souris

Commençons par écouter l'événement 'click' sur notre conteneur d'histoires. Ajoutez le code en surbrillance à 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')
})

Si un clic se produit et qu'il ne s'agit pas d'un élément <article>, nous abandonnons et ne faisons rien. S'il s'agit d'un article, nous récupérons la position horizontale de la souris ou du doigt avec clientX. Nous n'avons pas encore implémenté navigateStories, mais l'argument qu'il prend spécifie la direction à suivre. Si la position de l'utilisateur est supérieure à la médiane, nous savons que nous devons naviguer vers next, sinon vers prev (précédent).

Clavier

Écoutons maintenant les frappes au clavier. Si l'utilisateur appuie sur la flèche vers le bas, il est redirigé vers next. Si c'est la flèche vers le haut, nous passons à prev.

Ajoutez le code en surbrillance à 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')
})

Navigation dans les stories

Il est temps de s'attaquer à la logique métier unique des stories et à l'UX qui les a rendues célèbres. Cela peut sembler compliqué, mais je pense que si vous le prenez ligne par ligne, vous verrez que c'est assez simple.

Au début, nous stockons des sélecteurs qui nous aident à décider de faire défiler l'écran jusqu'à un ami ou d'afficher/masquer une story. Comme nous travaillons dans le code HTML, nous allons l'interroger pour savoir si des amis (utilisateurs) ou des histoires (story) sont présents.

Ces variables nous aideront à répondre à des questions telles que "Étant donné l'histoire x, "suivant" signifie-t-il passer à une autre histoire du même ami ou à une histoire d'un autre ami ?". Pour ce faire, j'ai utilisé la structure arborescente que nous avions créée, en accédant aux parents et à leurs enfants.

Ajoutez le code suivant en bas de 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
}

Voici notre objectif de logique métier, aussi proche que possible du langage naturel :

  • Décidez comment gérer l'appui.
    • S'il existe une histoire suivante/précédente : afficher cette histoire
    • S'il s'agit de la dernière/première story de l'ami : affichez un nouvel ami.
    • S'il n'y a pas d'histoire dans cette direction, ne faites rien.
  • Ajoute l'article actuel à state

Ajoutez le code mis en surbrillance à votre fonction 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
    }
  }
}

Essayer

  • Pour prévisualiser le site, appuyez sur Afficher l'application, puis sur Plein écran plein écran.

Conclusion

C'est tout pour les besoins que j'avais avec le composant. N'hésitez pas à vous en inspirer, à l'alimenter avec des données et, en général, à vous l'approprier !