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

Cet atelier de programmation vous explique comment créer une expérience telle que les stories Instagram sur le Web. Nous allons créer le composant au fur et à mesure, en commençant par HTML, CSS, puis 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.

Préparation

  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. É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. Mais commençons par le 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 quelques é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 des 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 dans la section suivante.

CSS

Nos contenus sont prêts à être stylés. Transformons ces os en quelque chose avec les gens qui voudront interagir. Aujourd'hui, nous allons donner la priorité au mobile.

.stories

Pour notre conteneur <div class="stories">, nous voulons un conteneur à défilement horizontal. Nous pouvons y parvenir de la manière suivante:

  • Transformer le conteneur en grille
  • Configurer chaque enfant pour qu'il occupe la ligne de suivi
  • Adapter la largeur de chaque élément enfant à la largeur de la fenêtre d'affichage d'un appareil mobile

La grille continuera à placer de nouvelles colonnes de largeur 100vw à droite de la précédente, jusqu'à ce que tous les éléments HTML soient placés dans votre balisage.

Chrome et les outils de développement s&#39;ouvrent avec une grille montrant la mise en page en pleine largeur
Les outils pour les développeurs Chrome affichent un dépassement de colonnes dans la grille, créant ainsi un conteneur de 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 à ce 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 la page, nous voulons que le composant repose doucement sur la story suivante. Nous utilisons donc scroll-snap-type: x mandatory. Pour en savoir plus sur ce CSS, consultez les sections Points d'ancrage de défilement CSS et Comportement de défilement hors limites de mon article de blog.

Il faut que le conteneur parent et les enfants acceptent l'ancrage du défilement. Nous allons donc gérer cela 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é. Lorsque cette option est activée, chaque défilement horizontal passe à l'histoire suivante. Lorsque cette option est désactivée, le navigateur utilise son comportement de défilement par défaut.

Cela vous fera défiler la liste de vos amis, mais nous avons toujours un problème avec les histoires à résoudre.

.user

Créons une mise en page dans la section .user qui superpose ces éléments de l'histoire enfant. Nous allons utiliser une astuce d'empilement pratique pour résoudre ce problème. Nous créons essentiellement une grille 1 x 1 dans laquelle la ligne et la colonne ont le même alias de grille [story], et chaque élément de grille d'article va essayer de revendiquer cet espace, ce qui génère une pile.

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;
}

Désormais, sans positionnement absolu, valeurs flottantes ou autres directives de mise en page qui suppriment un élément du flux, nous continuons à utiliser le flux. En plus, c'est comme à peine du code, regardez ça ! Cela est décomposé dans la vidéo et l'article de blog plus en détail.

.story

Il ne nous reste plus qu'à appliquer un style à l'élément de l'histoire.

Comme indiqué précédemment, 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 classer dans un ordre afin que notre image d'utilisateur soit en haut et qu'elle apparaisse 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 avec l'espace réservé de chargement.

Tout d'abord, mettons à jour l'ensemble de règles .story pour remplacer un dégradé par une image de fond une fois le chargement terminé. 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 va le remplir. La définition de deux images de fond nous permet de générer une astuce Web CSS efficace appelée loading tombstone:

  • L'image de fond 1 (var(--bg)) est l'URL que nous avons intégrée 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)

Une fois le téléchargement terminé, CSS remplace automatiquement le dégradé par l'image.

Nous allons ensuite ajouter du code CSS pour supprimer un comportement et ainsi libérer le navigateur pour qu'il se déplace 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 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écider si vous cliquez ou non sur une URL.

Enfin, ajoutons un peu de CSS pour animer la transition entre les articles. 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;

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

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

La classe .seen sera ajoutée à une histoire nécessitant une sortie. J'ai récupéré la fonction de lissage de vitesse personnalisé (cubic-bezier(0.4, 0.0, 1,1)) dans le guide Lissage de vitesse de Material Design (faites défiler la page jusqu'à la section Lissage accéléré).

Si vous êtes attentif, vous avez probablement remarqué la déclaration pointer-events: none et vous vous grattez la tête en ce moment. 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 est placé en haut et reçoit des appuis, même s'il est invisible. En définissant pointer-events sur none, nous transformons l'histoire en verre en fenêtre et ne volons plus d'interactions utilisateur. C'est un compromis, pas trop difficile à gérer dans notre CSS pour le moment. Pas besoin de jongler avec z-index. Je me sens toujours bien.

JavaScript

Les interactions d'un composant "Stories" sont assez simples pour l'utilisateur: appuie sur la droite pour avancer et sur la gauche pour revenir en arrière. Les choses simples pour les utilisateurs ont tendance à être difficiles à travailler pour les développeurs. Nous nous en occupons beaucoup.

Préparation

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)

La première ligne de code JavaScript récupère et stocke une référence à la racine de l'élément HTML principal. La ligne suivante calcule l'emplacement du milieu de notre élément. Nous pouvons donc décider si un appui sert à avancer ou à reculer.

État

Ensuite, nous créons un petit objet avec un état pertinent pour notre logique. Dans ce cas, nous ne nous intéressons qu'à l'actualité. 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 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'articles. 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 ne se produit pas sur un élément <article>, nous annulons 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 à 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. S'il s'agit de la flèche vers le haut, nous allons accéder à 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 histoires et à l'expérience pour laquelle elles sont devenues célèbres. Cela a l'air en gros et délicat, mais je pense que si vous le prenez ligne par ligne, vous constaterez qu'il est assez digeste.

Au départ, nous introduisons des sélecteurs qui nous aident à décider s'il faut faire défiler la page jusqu'à un ami ou afficher/masquer une histoire. Comme nous travaillons au code HTML, nous allons l'interroger pour vérifier la présence d'amis (utilisateurs) ou d'histoires (histoire).

Ces variables nous aideront à répondre à des questions telles que : "Avec l'histoire x, est-ce que "suivant" signifie passer à une autre histoire du même ami ou d'un autre ami ?" Je l'ai fait en utilisant la structure arborescente que nous avons construite, 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:

  • Choisissez comment gérer l'appui.
    • S'il y a un événement suivant/précédent: afficher-le
    • Si c'est la dernière ou la première histoire de l'ami: montrez-lui un nouvel ami
    • S'il n'y a pas d'histoire à lire dans cette direction: ne faites rien
  • Rassemblez la nouvelle histoire dans state

Ajoutez le code 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 un résumé des besoins que j'avais avec le composant. N'hésitez pas à vous appuyer dessus, à les exploiter avec des données et, en général, à vous les approprier !