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

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

Configuration

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

HTML

J'essaie toujours d'utiliser le HTML sémantique. Comme chaque ami peut avoir autant d'histoires que vous le souhaitez, j'ai pensé qu'il était intéressant d'utiliser un un élément <section> pour chaque ami et un élément <article> pour chaque histoire. Commençons par le début. Tout d'abord, nous avons besoin d'un conteneur "Stories" de Google.

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

<div class="stories">

</div>

Ajoutez quelques é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 quelques éléments <article> pour représenter les 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 nous aider à prototyper les stories.
  • L'attribut style de chaque <article> fait partie d'un chargement d'espace réservé que vous découvrirez dans la section suivante.

CSS

Vous avez du style ! Transformons ces os en quelque chose que les gens apprécieront souhaitez interagir. Aujourd'hui, nous allons donner la priorité aux appareils mobiles.

.stories

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

  • Faire du conteneur une grille
  • Définir chaque enfant pour qu'il remplisse la piste de ligne
  • Convertir la largeur de chaque élément enfant sur celle de la fenêtre d'affichage d'un appareil mobile

La grille continuera de placer les nouvelles colonnes de 100vw de large à droite des colonnes précédentes jusqu'à ce qu'il soit placé tous les éléments HTML 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 montrent le dépassement des colonnes de la grille, ce qui donne 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, conteneur et 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 le défilement horizontal. Nous allons donc définir overflow-x sur auto Lorsque l'utilisateur fait défiler la page, le composant doit reposer doucement sur l'histoire suivante. Nous utiliserons donc scroll-snap-type: x mandatory. En savoir plus CSS dans les points d'ancrage de défilement CSS et overscroll-behavior de mon article de blog.

Il faut que le conteneur parent et les enfants acceptent de faire défiler l'ancrage. Ainsi, nous allons 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é. Lorsque cette option est activée, chaque ligne horizontale faites défiler des ancrages jusqu'à l'article suivant. Lorsque cette option est désactivée, le navigateur utilise comportement de défilement par défaut.

Cela vous fera faire défiler la liste de vos amis, mais le problème persiste avec les histoires à résoudre.

.user

Créons dans la section .user une mise en page qui superpose cette histoire enfant. les éléments en place. 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 la même grille alias de [story], et chaque élément de la grille de récit va essayer de revendiquer cet espace, ce qui entraîne 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, sans floats ni autres directives de mise en page qui prennent un élément hors flux, nous sommes toujours en flux. De plus, c'est comme à peine du code, regardez ça ! Cela est décomposé plus en détail dans la vidéo et l'article de blog.

.story

Il ne nous reste plus qu'à définir le style de l'élément de type "Histoire".

Comme indiqué précédemment, l'attribut style de chaque élément <article> fait partie d'une technique de chargement des espaces réservés:

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

Nous allons utiliser la propriété CSS background-image, qui nous permet de spécifier plusieurs images de fond. Nous pouvons les mettre dans un ordre afin que notre utilisateur est en haut et s'affichera automatiquement une fois le chargement terminé. À nous allons placer l'URL de l'image dans une propriété personnalisée (--bg) et l'utiliser. dans notre CSS pour la superposer avec l'espace réservé de chargement.

Commençons par modifier 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 car notre image la remplira. Définir deux images de fond permet d'exécuter une astuce Web CSS, appelée tombstone de chargement:

  • L'image de fond 1 (var(--bg)) correspond à 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

Le CSS remplace 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 permettra au navigateur de se déplacer 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és comme des événements tactiles, ce qui évite au navigateur décidez si vous cliquez ou non sur une URL

Enfin, ajoutons un peu de code CSS pour animer la transition entre les stories. Ajoutez le 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 story nécessitant une sortie. J'ai obtenu la fonction de lissage de vitesse personnalisé (cubic-bezier(0.4, 0.0, 1,1)) de l'atelier Easing de Material Design (faites défiler la page jusqu'à la section Lissage de vitesse accéléré).

Si vous avez l'œil attentif, vous avez probablement remarqué le pointer-events: none et vous vous grattez la tête en ce moment. Je dirais que c'est le seul l'inconvénient de la solution jusqu'à présent. Nous en avons besoin, car un élément .seen.story est en haut et reçoit des pressions, même s'il est invisible. En définissant pointer-events à none, nous transformons l'histoire en verre en fenêtre, et nous ne volons plus d'interactions utilisateur. Pas trop mal, c'est juste un compromis, pas trop difficile à gérer ici dans notre CSS. Nous ne jonglons pas avec z-index. Cela me convient à l'arrêt.

JavaScript

Les interactions avec le composant "Stories" sont assez simples pour l'utilisateur: appuyez sur l'icône vers la droite pour avancer, appuyez sur la gauche pour revenir en arrière. Les choses simples pour les utilisateurs ont tendance pour les développeurs. Nous nous occupons toutefois d'un grand nombre de choses.

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)

La première ligne de JavaScript récupère et stocke une référence à notre code HTML principal. racine de l'élément. La ligne suivante calcule où se trouve le milieu de notre élément, nous peuvent décider si un tapotement est d’avancer ou de reculer.

État

Ensuite, nous créons un petit objet avec un état pertinent pour notre logique. Dans ce nous ne nous intéressons qu'à l'histoire actuelle. Dans notre balisage HTML, nous pouvons accédez-y en saisissant le premier ami et son histoire la plus récente. Ajouter 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' dans notre conteneur de stories. 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 concerne pas un élément <article>, nous annulons la demande 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 qui qu'il prend spécifie dans la direction que nous devons aller. Si cette position de l’utilisateur est supérieure à la médiane, nous savons qu'il faut accéder à next, sinon prev (précédente).

Clavier

Écoutons maintenant les pressions du clavier. Si l'utilisateur appuie sur la flèche vers le bas, à next. Si c'est la flèche vers le haut, nous allons à 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

Abordons maintenant la logique métier unique des histoires et l'expérience utilisateur qu'ils deviennent célèbre. Cela semble lourd et délicat, mais je pense que si vous faites la même chose vous verrez que c'est assez digeste.

Nous enregistrons d'emblée quelques sélecteurs qui nous aident à décider s'il faut faire défiler ami ou afficher/masquer une histoire. Puisque c'est sur le HTML que nous travaillons, l’interroger pour la présence d’amis (utilisateurs) ou d’histoires (histoire).

Ces variables nous aideront à répondre à des questions telles que : « histoire donnée x, fait « suivant » passer à une autre histoire, de ce même ami ou d’un autre ami ? » Je l'ai fait en utilisant l'arbre que nous avons élaboré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, aussi proche que possible du langage naturel:

  • Décidez comment gérer le geste
    • S'il existe un article suivant/précédent: montrez-le
    • S'il s'agit de la dernière/première histoire de l'ami: montrez à un nouvel ami
    • S'il n'y a pas d'article vers lequel aller dans ce sens: ne rien faire
  • Placer la nouvelle histoire actuelle 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. Appuyez ensuite sur Plein écran plein écran

Conclusion

Voilà pour les besoins du composant. N'hésitez pas à vous appuyer sur vous les pilotez avec des données et, en général, vous les appropriez !