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

Cet atelier de programmation vous explique comment 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 le code HTML, puis le CSS, puis le 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 Remixer pour modifier pour rendre le projet modifiable.
  2. Ouvrez app/index.html.

HTML

Je m'efforce toujours d'utiliser un code HTML sémantique. Étant donné que chaque ami peut avoir un nombre illimité 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. Reprenons du début. Tout d'abord, nous avons besoin d'un conteneur pour notre composant "stories".

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

<div class="stories">

</div>

Ajoutez des éléments <section> pour représenter des 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 des stories:

<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 créer des prototypes d'histoires.
  • L'attribut style de chaque <article> fait partie d'une technique de chargement d'espace réservé, que vous découvrirez dans la section suivante.

CSS

Nos contenus sont prêts à être stylisés. Transformons ces os en quelque chose avec lequel les utilisateurs voudront interagir. Nous allons travailler en priorité sur le mobile aujourd'hui.

.stories

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

  • Faire du conteneur une grille
  • Définir chaque enfant pour qu'il remplisse la piste de ligne
  • Définir la largeur de chaque enfant sur la largeur de la vue d'un appareil mobile

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

Chrome et les outils pour les développeurs s&#39;ouvrent avec un visuel de grille montrant la mise en page pleine largeur
Outils pour les développeurs Chrome affichant le débordement de la colonne de la 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 dépasse 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 souhaitons un défilement horizontal. Nous allons donc définir overflow-x sur auto. Lorsque l'utilisateur fait défiler la page, nous voulons que le composant 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 CSS Scroll Snap Points (Points d'ancrage de défilement CSS) et overscroll-behavior (comportement de défilement excessif) de mon article de blog.

Le conteneur parent et les enfants doivent accepter le forçage du défilement. Commençons par cela. 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é. Lorsque cette option est activée, chaque défilement horizontal s'arrête sur la story suivante. Si elle est désactivée, le navigateur utilise son comportement de défilement par défaut.

Vous allez faire défiler vos amis, mais nous avons encore un problème avec les stories à résoudre.

.user

Créons une mise en page dans la section .user qui place ces éléments de story enfant. Pour résoudre ce problème, nous allons utiliser une astuce d'empilement pratique. 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 de l'histoire va essayer de revendiquer cet espace, ce qui entraînera une pile.

Ajoutez le code mis 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 le jeu de règles suivant en bas de app/css/index.css:

.story {
  grid-area: story;
}

Maintenant, sans positionnement absolu, flottants ni autres directives de mise en page qui retirent 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 détaillé dans la vidéo et l'article de blog.

.story

Il ne nous reste plus qu'à styliser l'élément de récit 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 du CSS, qui nous permet de spécifier plusieurs images de fond. Nous pouvons les mettre dans un ordre afin que notre photo d'utilisateur soit en haut et s'affiche automatiquement une fois le chargement terminé. Pour ce faire, nous allons placer notre URL d'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 le jeu de règles .story pour remplacer un dégradé par une image de fond une fois le chargement terminé. 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));
}

Définir background-size sur cover garantit qu'il n'y a pas d'espace vide dans le viewport, car notre image le remplira. Définir deux images de fond nous permet d'utiliser une astuce Web CSS appelée loading tombstone (pierre tombale de chargement) :

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

Le CSS remplacera automatiquement le dégradé par l'image une fois le téléchargement terminé.

Nous allons ensuite ajouter du CSS pour supprimer certains comportements, ce qui libère le navigateur et lui permet de s'exécuter plus rapidement. 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;
}
  • user-select: none empêche les utilisateurs de sélectionner accidentellement du texte.
  • 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 stories. 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 est ajoutée à une histoire qui nécessite une sortie. J'ai obtenu la fonction d'atténuation personnalisée (cubic-bezier(0.4, 0.0, 1,1)) dans le guide Atténuation de Material Design (faites défiler la page jusqu'à la section Atténuation accélérée).

Si vous avez l'œil, vous avez probablement remarqué la déclaration pointer-events: none et vous vous demandez peut-être pourquoi. Je dirais que c'est le seul inconvénient de la solution jusqu'à présent. Nous avons besoin de cela, car un élément .seen.story se trouve en haut et reçoit des pressions, même s'il est invisible. En définissant pointer-events sur none, nous transformons l'histoire de verre en fenêtre et ne volons plus aucune interaction 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 satisfait de cette décision.

JavaScript

Les interactions d'un composant Stories sont assez simples pour l'utilisateur: appuyez sur la droite pour avancer, appuyez sur la gauche pour revenir en arrière. Les choses simples pour les utilisateurs sont souvent difficiles pour les développeurs. Nous nous en occuperons en grande partie.

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 code JavaScript récupère et stocke une référence à la racine de notre élément HTML principal. La ligne suivante calcule l'emplacement du milieu de notre élément afin que nous puissions décider si un appui doit se faire en avant ou en arrière.

État

Nous créons ensuite un petit objet avec un état pertinent pour notre logique. Dans ce cas, nous ne nous intéressons qu'à l'histoire en cours. Dans notre balisage HTML, nous pouvons y accéder en récupérant le premier ami et sa story la plus récente. Ajoutez le code mis en surbrillance à votre 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 disposons désormais de 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 mis 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 concerne pas un élément <article>, nous abandonnons et ne faisons rien. S'il s'agit d'un article, nous saisissons 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 à prendre. Si cette position de l'utilisateur est supérieure à la médiane, nous savons que nous devons accéder à next, sinon à prev (précédent).

Clavier

Écoutons maintenant les pressions sur le clavier. Si vous appuyez sur la flèche vers le bas, vous accédez à next. Si l'icône est la flèche vers le haut, nous allons à prev.

Ajoutez le code mis 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'expérience utilisateur pour laquelle elles sont devenues célèbres. Cela semble lourd et compliqué, mais si vous le prenez ligne par ligne, vous constaterez qu'il est assez digeste.

Au départ, nous stockons des sélecteurs qui nous aident à décider de faire défiler un ami ou d'afficher/masquer une story. Étant donné que nous travaillons sur le code HTML, nous allons l'interroger pour savoir s'il contient des amis (utilisateurs) ou des histoires (story).

Ces variables nous aideront à répondre à des questions telles que "Étant donné l'histoire X, "suivant" signifie-t-il passer à une autre histoire de cet ami ou à celle d'un autre ami ?". J'ai fait cela en utilisant la structure arborescente que nous avons créée, en touchant les 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, le plus proche possible du langage naturel:

  • Décider de la manière de gérer le contact
    • Si une story suivante/précédente est disponible, l'afficher
    • S'il s'agit de la première ou de la dernière story de l'ami: afficher un nouvel ami
    • S'il n'y a pas d'histoire à développer dans cette direction, ne faites rien.
  • Placer la nouvelle histoire actuelle dans 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 concernant le composant. N'hésitez pas à l'utiliser, à l'alimenter avec des données et, en général, à l'adapter à vos besoins.