Créer un composant Story avec LitElement

Aujourd'hui, les stories sont un composant d'interface utilisateur souvent utilisé dans les flux des applications de réseaux sociaux et d'actualités. Dans cet atelier de programmation, vous allez créer un composant Story avec LitElement et TypeScript.

Voici comment celui-ci se présentera une fois terminé :

Composant story-viewer affichant trois images sur le thème du café

On peut considérer une story de réseau social ou d'actualité comme une collection de fiches qui sont lues de manière séquentielle, à la manière d'un diaporama. Les stories sont en fait des diaporamas. En règle générale, les fiches qui les composent contiennent une image ou une vidéo en lecture automatique avec, en option, du texte en superposition. Voici les fonctionnalités que nous allons aborder :

Liste des fonctionnalités

  • Fiches contenant une image ou une vidéo en arrière-plan
  • Balayage de l'écran vers la gauche ou la droite pour parcourir la story
  • Lecture automatique des vidéos
  • Ajout de texte et personnalisation des fiches

D'un point de vue de développeur, il serait pratique de pouvoir définir les fiches de stories en utilisant du code HTML brut, comme dans l'exemple ci-dessous :

<story-viewer>
  <story-card>
    <img slot="media" src="some/image.jpg" />
    <h1>Title</h1>
  </story-card>
  <story-card>
    <video slot="media" src="some/video.mp4" loop playsinline></video>
    <h1>Whatever</h1>
    <p>I want!</p>
  </story-card>
</story-viewer>

Nous aborderons donc également cette fonctionnalité.

Liste des fonctionnalités

  • Rédaction de fiches en code HTML

Cette fonctionnalité permet à quiconque d'utiliser le composant "Story" en écrivant simplement du code HTML. Elle convient aussi bien aux programmeurs qu'aux non-programmeurs et peut être appliquée partout où le code HTML est utilisé, qu'il s'agisse de systèmes de gestion de contenu, de frameworks ou autres.

Conditions préalables

  • Une interface système dans laquelle vous pouvez exécuter git et npm
  • Un éditeur de texte

Commencez par cloner le dépôt story-viewer-starter :

git clone git@github.com:PolymerLabs/story-viewer-starter.git

L'environnement est déjà configuré avec LitElement et TypeScript. Il vous suffit d'installer les dépendances :

npm i

Si vous utilisez VS Code, installez l'extension lit-plugin qui permet la saisie semi-automatique du code, la validation de types et le linting des modèles lit-html.

Lancez l'environnement de développement en exécutant la commande suivante :

npm run dev

Vous pouvez maintenant commencer à coder.

Lorsque vous créez des composants composés, il est parfois plus simple de commencer par créer des sous-composants.Vous allez donc créer <story-card>, qui permet d'afficher un élément (image ou vidéo) à fond perdu. Les utilisateurs pourront ensuite le personnaliser, en superposant du texte, par exemple.

La première étape consiste à définir la classe du composant, qui étend LitElement. Le décorateur customElement s'occupe de l'enregistrement de l'élément personnalisé. Le moment est venu d'activer les décorateurs dans votre fichier tsconfig à l'aide de l'indicateur experimentalDecorators (si vous utilisez le dépôt de démarrage, il est déjà activé).

Copiez le code suivant dans le fichier story-card.ts :

import { LitElement, customElement } from 'lit-element';

@customElement('story-card')
export class StoryCard extends LitElement {
}

<story-card> est utilisable en tant qu'élément personnalisé, mais il n'y a rien à afficher pour le moment. Pour définir sa structure interne, définissez la méthode d'instance render. Vous allez indiquer le modèle de l'élément à l'aide de la balise html de lit-html.

Que doit contenir ce modèle ? L'utilisateur devrait pouvoir fournir deux éléments : un élément multimédia et un élément en superposition. Ajoutez donc un emplacement (<slot>) pour chacun d'entre eux.

Les emplacements permettent de définir comment les enfants d'un élément personnalisé doivent s'afficher. Pour en savoir plus, voici un excellent tutoriel sur l'utilisation des emplacements.

import { html } from 'lit-html';

export class StoryCard extends LitElement {
  render() {
    return html`
      <div id="media">
        <slot name="media"></slot>
      </div>
      <div id="content">
        <slot></slot>
      </div>
    `;
  }
}

En prévoyant un emplacement séparé pour l'élément multimédia, vous pourrez cibler cet élément pour, en autres, ajouter un style à fond perdu et des vidéos en lecture automatique. Ajoutez le deuxième emplacement (celui des superpositions personnalisées) dans un élément de conteneur qui vous permettra de définir plus tard la marge intérieure par défaut.

Le composant <story-card> peut désormais être utilisé comme suit :

<story-card>
  <img slot="media" src="some/image.jpg" />
  <h1>My Title</h1>
  <p>my description</p>
</story-card>

Mais pour l'instant, il ne ressemble à rien :

Composant story-viewer ne contenant aucun style, affichant une image sur le thème du café

Ajouter un style

Vous allez maintenant ajouter un style. Pour LitElement, il suffit de définir une propriété styles statique et de renvoyer une chaîne de modèle dotée du tag css. Le code CSS que vous insérez ne s'applique qu'à l'élément personnalisé. C'est l'avantage d'utiliser du code CSS avec le Shadow DOM.

Appliquez maintenant un style à l'emplacement de l'élément multimédia pour qu'il recouvre le composant <story-card>. C'est également un bon moment pour améliorer la mise en forme des éléments du deuxième emplacement. Les utilisateurs du composant pourront ainsi placer des éléments de type <h1>, <p> ou autre et bénéficier d'un affichage fonctionnel par défaut.

import { css } from 'lit-element';

export class StoryCard extends LitElement {
  static styles = css`
    #media {
      height: 100%;
    }
    #media ::slotted(*) {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }

    /* Default styles for content */
    #content {
      position: absolute;
      top: 0;
      right: 0;
      bottom: 0;
      left: 0;
      padding: 48px;
      font-family: sans-serif;
      color: white;
      font-size: 24px;
    }
    #content > slot::slotted(*) {
      margin: 0;
    }
  `;
}

Composant story-viewer stylisé, affichant une image sur le thème du café

Vous disposez à présent de fiches de stories avec des contenus multimédias en arrière-plan, auxquels vous pouvez superposer d'autres éléments. Parfait ! Nous reviendrons plus tard à la classe StoryCard pour implémenter la lecture automatique des vidéos.

L'élément <story-viewer> est le parent de <story-card>. Il permettra de disposer les fiches horizontalement et de passer d'une fiche à l'autre en balayant l'écran.Vous allez procéder de la même manière que pour StoryCard, en ajoutant des fiches de stories en tant qu'enfants de l'élément <story-viewer>. Vous devez donc ajouter un emplacement pour ces enfants.

Copiez le code suivant dans le fichier story-viewer.ts :

import { LitElement, customElement, html } from 'lit-element';

@customElement('story-viewer')
export class StoryViewer extends LitElement {
  render() {
    return html`<slot></slot>`;
  }
}

Ajoutez une mise en page horizontale. Pour cela, il suffit d'attribuer un positionnement absolu à l'ensemble des éléments <story-card> et d'appliquer une translation en fonction de leur index. Vous pouvez cibler l'élément <story-viewer> lui-même à l'aide du sélecteur :host.

static styles = css`
  :host {
    display: block;
    position: relative;
    /* Default size */
    width: 300px;
    height: 800px;
  }
  ::slotted(*) {
    position: absolute;
    width: 100%;
    height: 100%;
  }`;

L'utilisateur peut ainsi contrôler la taille des fiches de stories en modifiant simplement la hauteur et la largeur par défaut au niveau de l'hôte. Exemple :

story-viewer {
  width: 400px;
  max-width: 100%;
  height: 80%;
}

Pour mapper la fiche actuellement affichée, ajoutez une variable d'instance index à la classe StoryViewer. Le fait de décorer le composant avec la propriété @property de LitElement entraîne un nouveau rendu chaque fois que sa valeur est modifiée.

import { property } from 'lit-element';

export class StoryViewer extends LitElement {
  @property({type: Number}) index: number = 0;
}

Pour chaque fiche, une translation horizontale doit être appliquée. Appliquez ces translations dans la méthode de cycle de vie update de LitElement. La méthode de mise à jour s'exécute chaque fois qu'une propriété observée de ce composant est modifiée. En règle générale, vous devez interroger slot.assignedElements() en envoyant une requête concernant l'emplacement et la boucle. Cependant, comme vous ne disposez que d'un emplacement sans nom, cela revient à utiliser this.children. Pour plus de commodité, vous allez donc utiliser this.children.

import { PropertyValues } from 'lit-element';

export class StoryViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    const width = this.clientWidth;
    Array.from(this.children).forEach((el: Element, i) => {
      const x = (i - this.index) * width;
      (el as HTMLElement).style.transform = `translate3d(${x}px,0,0)`;
    });
    super.update(changedProperties);
  }
}

Les éléments <story-card> sont désormais placés les uns à la suite des autres. Cette méthode fonctionne avec d'autres éléments en tant qu'enfants, à condition d'appliquer un style approprié :

<story-viewer>
  <!-- A regular story-card child... -->
  <story-card>
    <video slot="media" src="some/video.mp4"></video>
    <h1>This video</h1>
    <p>is so cool.</p>
  </story-card>
  <!-- ...and other elements work too! -->
  <img style="object-fit: cover" src="some/img.png" />
</story-viewer>

Accédez au fichier build/index.html, puis annulez la mise en commentaire des autres éléments du composant story-card. Maintenant, vous allez rendre ces éléments accessibles.

Il faudra ensuite ajouter un moyen de passer d'une fiche à l'autre, ainsi qu'une barre de progression.

Ajoutez quelques fonctions d'assistance à StoryViewer permettant à l'utilisateur de parcourir la story. Celles-ci définissent l'index tout en spécifiant une plage valide.

Dans le fichier story-viewer.ts, ajoutez le code suivant au niveau de la classe StoryViewer :

/** Advance to the next story card if possible **/
next() {
  this.index = Math.max(0, Math.min(this.children.length - 1, this.index + 1));
}

/** Go back to the previous story card if possible **/
previous() {
  this.index = Math.max(0, Math.min(this.children.length - 1, this.index - 1));
}

Pour proposer la navigation à l'utilisateur final, vous allez ajouter les boutons "Précédent" et "Suivant" au composant <story-viewer>. Lorsque l'utilisateur clique sur l'un de ces boutons, il faudrait appeler la fonction d'assistance next ou previous. lit-html permet d'ajouter facilement des écouteurs d'événements aux éléments. Il est ainsi possible d'afficher les boutons et d'ajouter un écouteur de clics.

Mettez à jour la méthode render comme suit :

export class StoryViewer extends LitElement {
  render() {
    return html`
      <slot></slot>

      <svg id="prev" viewBox="0 0 10 10" @click=${() => this.previous()}>
        <path d="M 6 2 L 4 5 L 6 8" stroke="#fff" fill="none" />
      </svg>
      <svg id="next" viewBox="0 0 10 10" @click=${() => this.next()}>
        <path d="M 4 2 L 6 5 L 4 8" stroke="#fff" fill="none" />
      </svg>
    `;
  }
}

Voilà donc comment ajouter des écouteurs d'événements intégrés aux boutons au format SVG directement dans la méthode render. Cela fonctionne pour tous les événements. Il vous suffit d'ajouter une liaison de type @eventname=${handler} à un élément.

Ajoutez le code suivant à la propriété static styles pour appliquer un style aux boutons :

svg {
  position: absolute;
  top: calc(50% - 25px);
  height: 50px;
  cursor: pointer;
}
#next {
  right: 0;
}

Pour la barre de progression, vous allez utiliser la grille CSS afin d'appliquer un style à de petits encadrés, un pour chaque fiche de story. Vous pouvez utiliser la propriété index pour ajouter de manière conditionnelle des classes dans les encadrés selon qu'elles ont été "vues" ou non. Si vous utilisez une expression conditionnelle telle que i <= this.index : 'watched': '', cela risque de compliquer la situation à mesure que vous ajoutez des classes. Heureusement, lit-html comporte une instruction appelée classMap qui vous simplifie la tâche. Commencez par importer classMap :

import { classMap } from 'lit-html/directives/class-map';

Ajoutez le code suivant en bas de la méthode render :

<div id="progress">
  ${Array.from(this.children).map((_, i) => html`
    <div
      class=${classMap({watched: i <= this.index})}
      @click=${() => this.index = i}
    ></div>`
  )}
</div>

Le code inclut des gestionnaires de clics supplémentaires pour que les utilisateurs puissent passer directement à une fiche de story spécifique s'ils le souhaitent.

Voici les nouveaux styles à ajouter à static styles :

::slotted(*) {
  position: absolute;
  width: 100%;
  /* Changed this line! */
  height: calc(100% - 20px);
}

#progress {
  position: relative;
  top: calc(100% - 20px);
  height: 20px;
  width: 50%;
  margin: 0 auto;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  grid-gap: 10px;
  align-content: center;
}
#progress > div {
  background: grey;
  height: 4px;
  transition: background 0.3s linear;
  cursor: pointer;
}
#progress > div.watched {
  background: white;
}

Vous venez de terminer la barre de progression et de navigation. Maintenant, il est temps d'ajouter une touche de style !

Pour implémenter le balayage, vous allez utiliser la bibliothèque de commandes gestuelles Hammer.js. Hammer détecte les gestes spéciaux, comme les gestes panoramiques, et envoie les événements accompagnés d'informations pertinentes (comme delta X) que vous pouvez utiliser.

Voici comment utiliser Hammer pour détecter les gestes panoramiques et mettre à jour automatiquement votre élément chaque fois qu'un tel événement se produit :

import { internalProperty } from 'lit-element';
import 'hammerjs';

export class StoryViewer extends LitElement {
  // Data emitted by Hammer.js
  @internalProperty() _panData: {isFinal?: boolean, deltaX?: number} = {};

  constructor() {
    super();
    this.index = 0;
    new Hammer(this).on('pan', (e: HammerInput) => this._panData = e);
  }
}

Le constructeur d'une classe LitElement est un autre excellent emplacement pour associer des écouteurs d'événements à l'élément hôte lui-même. Le constructeur Hammer nécessite un élément pour lequel appliquer la détection des gestes. Ici, il s'agit de StoryViewer ou de this. Ensuite, à l'aide de l'API Hammer, vous lui demandez de détecter le geste panoramique et de définir les informations le concernant dans une nouvelle propriété _panData.

Si vous décorez la propriété _panData avec @internalProperty, LitElement surveillera les modifications apportées à _panData et effectuera une mise à jour. Cependant, la propriété ne sera PAS reflétée dans un attribut.

Développez maintenant la logique update pour qu'elle utilise les données du geste panoramique :

// Update is called whenever an observed property changes.
update(changedProperties: PropertyValues) {
  // deltaX is the distance of the current pan gesture.
  // isFinal is whether the pan gesture is ending.
  let { deltaX = 0, isFinal = false } = this._panData;
  // When the pan gesture finishes, navigate.
  if (!changedProperties.has('index') && isFinal) {
    deltaX > 0 ? this.previous() : this.next();
  }
  // We don't want any deltaX when releasing a pan.
  deltaX = isFinal ? 0 : deltaX;
  const width = this.clientWidth;
  Array.from(this.children).forEach((el: Element, i) => {
    // Updated this line to utilize deltaX.
    const x = (i - this.index) * width + deltaX;
    (el as HTMLElement).style.transform = `translate3d(${x}px,0,0)`;
  });

  // Don't forget to call super!
  super.update(changedProperties);
}

Il est désormais possible de faire glisser les fiches des stories sur la droite ou sur la gauche. Pour un rendu plus fluide, revenez à static get styles et ajoutez transition: transform 0.35s ease-out; au sélecteur ::slotted(*) :

::slotted(*) {
  ...
  transition: transform 0.35s ease-out;
}

Maintenant, le balayage est fluide :

Balayage fluide lors du passage d'une fiche de story à l'autre

La dernière fonctionnalité à implémenter est la lecture automatique des vidéos. Lorsqu'une fiche de story entre au premier plan, la vidéo doit se lancer automatiquement en arrière-plan, le cas échéant. Lorsqu'elle n'est plus au premier plan, la vidéo doit être mise en pause.

Pour implémenter cette fonctionnalité, à chaque modification de l'index, vous allez envoyer des événements personnalisés "Entered" et "Exited" aux enfants concernés. Le composant StoryCard permettra de recevoir ces événements et de lancer ou mettre en pause les vidéos existantes. Pourquoi choisir d'envoyer les événements aux enfants au lieu d'appeler les méthodes d'instance "Entered" et "Exited" définies dans StoryCard ? Avec ces méthodes, les utilisateurs sont contraints de créer un élément personnalisé s'ils souhaitent écrire leur propre fiche de story à l'aide d'animations personnalisées. Avec les événements, au contraire, il suffit d'associer un écouteur d'événements.

Refactorisez la propriété index de StoryViewer afin d'utiliser un setter, qui fournit un chemin d'accès pratique pour envoyer les événements :

class StoryViewer extends LitElement {
  @internalProperty() private _index: number = 0
  get index() {
    return this._index
  }
  set index(value: number) {
    this.children[this._index].dispatchEvent(new CustomEvent('exited'));
    this.children[value].dispatchEvent(new CustomEvent('entered'));
    this._index = value;
  }
}

Pour terminer la fonctionnalité de lecture automatique, vous devez ajouter des écouteurs d'événements pour les événements "Entered" et "Existed" dans le constructeur StoryCard assurant la lecture et la mise en pause de la vidéo.

N'oubliez pas que l'utilisateur du composant peut indiquer ou non un élément vidéo dans <story-card> pour l'emplacement multimédia, ou encore ne fournir aucun élément. Vous devez donc veiller à ne pas appeler play pour un élément img ou une valeur nulle.

Revenez au fichier story-card.ts et ajoutez les éléments suivants :

import { query } from 'lit-element';

class StoryCard extends LitElement {
  constructor() {
    super();

    this.addEventListener("entered", () => {
      if (this._slottedMedia) {
        this._slottedMedia.currentTime = 0;
        this._slottedMedia.play();
      }
    });

    this.addEventListener("exited", () => {
      if (this._slottedMedia) {
        this._slottedMedia.pause();
      }
    });
  }

 /**
  * The element in the "media" slot, ONLY if it is an
  * HTMLMediaElement, such as <video>.
  */
 private get _slottedMedia(): HTMLMediaElement|null {
   const el = this._mediaSlot && this._mediaSlot.assignedNodes()[0];
   return el instanceof HTMLMediaElement ? el : null;
 }

  /**
   * @query(selector) is shorthand for
   * this.renderRoot.querySelector(selector)
   */
  @query("slot[name=media]")
  private _mediaSlot!: HTMLSlotElement;
}

Vous venez d'implémenter la lecture automatique. ✅

Maintenant que vous disposez de toutes les fonctionnalités essentielles, vous allez, pour finir, ajouter l'effet de mise à l'échelle. Revenez à nouveau à la méthode update de StoryViewer. Certains calculs sont effectués pour obtenir la valeur de la constante scale. Celle-ci sera égale à 1.0 pour l'enfant actif et à minScale dans les autres cas, une interpolation pouvant également être effectuée entre ces deux valeurs.

Remplacez la boucle dans la méthode update du fichier story-viewer.ts comme suit :

update(changedProperties: PropertyValues) {
  // ...
  const minScale = 0.8;
  Array.from(this.children).forEach((el: Element, i) => {
    const x = (i - this.index) * width + deltaX;

    // Piecewise scale(deltaX), looks like: __/\__
    const u = deltaX / width + (i - this.index);
    const v = -Math.abs(u * (1 - minScale)) + 1;
    const scale = Math.max(v, minScale);
    // Include the scale transform
    (el as HTMLElement).style.transform = `translate3d(${x}px,0,0) scale(${scale})`;
  });
  // ...
}

C'est terminé ! Dans cet article, nous avons abordé de nombreux points, y compris certaines fonctionnalités de LitElement et de lit-html, ainsi que les éléments HTML représentant un emplacement et les commandes gestuelles.

Pour obtenir une version complète de ce composant, consultez la page https://github.com/PolymerLabs/story-viewer.