Compila un componente de Historias con LitElement

En la actualidad, las historias son componentes de IU populares. Las apps de redes sociales y noticias las integran en sus feeds. En este codelab, compilarás un componente web de Historias con LitElement y TypeScript.

Así se verá el componente de la historia al final:

Un componente para visualizar historias que muestra tres imágenes de café

Podemos pensar en una "historia" para redes sociales o noticias como una colección de tarjetas para que se reproduzcan de manera secuencial, como una presentación de diapositivas. Las historias son, en realidad, presentaciones de diapositivas. En general, en las tarjetas predomina una imagen o un video de reproducción automática y pueden tener texto adicional en la parte superior. Qué compilaremos

Lista de funciones

  • Tarjetas con una imagen o video de fondo
  • Deslizar hacia la izquierda o la derecha para navegar por la historia
  • Reproducir videos automáticamente
  • Agregar texto o personalizar tarjetas de alguna otra manera

En lo que respecta a la experiencia del desarrollador de este componente, sería bueno especificar tarjetas de historias en lenguaje de marcado HTML sin formato, como el siguiente:

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

Agreguemos eso también a la lista de funciones.

Lista de funciones

  • Aceptar una serie de tarjetas en lenguaje de marcado HTML

De esta manera, cualquiera puede usar nuestro componente de historias con solo escribir HTML. Esta es una gran opción para programadores y no programadores y funciona en todas partes donde lo hace HTML, como sistemas de administración de contenido, frameworks, etc.

Requisitos previos

  • Un shell en el que puedes ejecutar git y npm
  • Un editor de texto

Comienza por clonar este repositorio: story-viewer-starter

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

El entorno ya está configurado con lit-element y TypeScript. Solo debes instalar las dependencias:

npm i

Para los usuarios de VS Code, instala la extensión lit-plugin para realizar el autocompletado, la comprobación de tipos y el análisis de plantillas de lit-html.

Para iniciar el entorno de desarrollo, ejecuta lo siguiente:

npm run dev

Ya puedes comenzar a programar

Cuando se compilan componentes compuestos, a veces es más sencillo comenzar con los subcomponentes más simples y luego avanzar. Comencemos con la compilación de <story-card>. Debe mostrar un video completo o una imagen. Los usuarios deben poder personalizarlo aún más, por ejemplo, con un texto superpuesto.

El primer paso es definir la clase de nuestro componente, que extiende la clase LitElement. El decorador customElement registra el elemento personalizado por nosotros. Ahora es un buen momento para asegurarte de habilitar los decoradores en tu tsconfig con la marca experimentalDecorators (si usas el repositorio de inicio, ya está habilitado).

Ingresa el siguiente código en story-card.ts:

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

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

Ahora <story-card> es un elemento personalizado que se puede usar, pero aún no hay nada para mostrar. Para definir la estructura interna del elemento, usa el método de instancia render. Aquí proporcionaremos la plantilla para el elemento mediante la etiqueta html lit-html.

¿Qué debe incluir la plantilla de este componente? El usuario debe poder proporcionar dos elementos: uno multimedia y una superposición. Por lo tanto, agregaremos un <slot> para cada uno de esos elementos.

En slots especificamos qué elementos hijos de un elemento personalizado deben ser renderizados. Para obtener más información, aquí hay una excelente explicación sobre el uso de slots.

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

Separar cada elemento multimedia en su propio slot nos ayudará a orientar dicho elemento para agregarle estilo completo y reproducir videos de manera automática. Coloca el segundo slot (el de las superposiciones personalizadas) dentro de un elemento contenedor a fin de poder proporcionar un relleno predeterminado más adelante.

El componente <story-card> ahora se puede usar de la siguiente manera:

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

Pero se ve bastante mal:

un visualizador de historias sin estilo que muestra una imagen de café

Agrega estilos

Agreguemos algo de estilo. Con lit-element, lo hacemos definiendo una propiedad estática styles y que muestra una plantilla de string etiquetada con css. Cualquier CSS que se escriba aquí solo aplica a nuestro elemento personalizado. CSS con shadow DOM queda muy bien de esta forma.

Aplicaremos estilo al elemento multimedia entre los slots para cubrir el <story-card>. En este paso, podemos proporcionar un buen formato para los elementos del segundo slot. De esa manera, los usuarios de componentes pueden incluir algunos <h1>, <p> o lo que deseen y ver algo interesante de forma predeterminada.

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

un visualizador de historias con estilo que muestra una imagen de café

Ahora tenemos tarjetas de historias con fondos multimedia y podemos poner lo que deseamos en la parte superior. ¡Genial! Volveremos a la clase StoryCard en un momento, para implementar videos de reproducción automática.

Nuestro elemento <story-viewer> es el superior de <story-card>. Se encargará de ubicar las tarjetas de manera horizontal para desplazarnos entre ellas. Empezaremos de la misma manera que lo hicimos para StoryCard. Queremos agregar tarjetas de historias como hijos del elemento <story-viewer>, así que debes agregar un slot para esos hijos.

Agrega el siguiente código en story-viewer.ts:

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

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

Luego debes agregar el diseño horizontal. Podemos posicionar de manera absoluta todos los slots <story-card> y trasladarlos según su índice. Podemos seleccionar el elemento <story-viewer> con el selector :host.

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

El usuario puede anular de manera externa la altura y el ancho predeterminados en el host a fin controlar el tamaño de nuestras tarjetas de historias. De la siguiente forma:

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

Para realizar un seguimiento de la tarjeta que se está viendo, agregaremos una variable de instancia index a la clase StoryViewer. Decorar con el elemento @property de LitElement hará que el componente se vuelva a renderizar cada vez que su valor cambie.

import { property } from 'lit-element';

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

Cada tarjeta debe trasladarse horizontalmente a su posición. Traslademos estos al método de ciclo de vida update de lit-element. El método de actualización se ejecutará cada vez que se observe un cambio en la propiedad del componente. Por lo general, consultaríamos el slot y el bucle sobre slot.assignedElements(). Sin embargo, como solo tenemos un slot sin nombre, esto es lo mismo que usar this.children. Usemos this.children para mayor practicidad.

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

Ahora, nuestros <story-card> están todos en fila. Funciona igual que con otros elementos como hijos, siempre y cuando nos ocupemos de darles el estilo adecuado:

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

Ve a build/index.html y quita el comentario del resto de los elementos de story-card. Ahora, podemos crearlos y navegar por ellos.

A continuación, agregaremos una manera de navegar entre las tarjetas y una barra de progreso.

Agreguemos algunas funciones auxiliares a StoryViewer para navegar por la historia. Establecerán el índice mientras lo restringe a un rango válido.

En story-viewer.ts, en la clase StoryViewer, agrega lo siguiente:

/** 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));
}

Agregaremos los botones "Anterior" y "Siguiente" al <story-viewer>.para que el usuario final pueda implementar la navegación. Cuando se hace clic en cualquiera de los botones, queremos llamar a la función auxiliar next o previous. Lit-html simplifica la adición de objetos de escucha de eventos a los elementos. Podemos renderizar los botones y agregar un objeto de escucha de clics al mismo tiempo.

Actualiza el método render de la siguiente forma:

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

Consulta cómo podemos agregar objetos de escucha de eventos intercalados en nuestros botones de new svg, directamente en el método render. Esto funciona para cualquier evento. Solo agrega una vinculación con el formato @eventname=${handler} a un elemento.

Agrega lo siguiente a la propiedad static styles para definir el estilo de los botones:

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

Para la barra de progreso, usaremos la cuadrícula de CSS con el fin de diseñar estilos pequeños, uno para cada tarjeta de historias. Podemos usar la propiedad index para agregar de manera condicional clases a los cuadros a fin de indicar si "se vieron" o no. Podríamos usar una expresión condicional, como i <= this.index : 'watched': '', pero esto se puede volver muy detallado si agregamos más clases. Afortunadamente, lit-html envía una directiva llamada classMap para ayudar. Primero, importa classMap:

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

Agrega el siguiente lenguaje de marcado a la parte inferior del método render:

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

También incorporamos otros controladores de clics para que los usuarios puedan pasar directamente a una tarjeta de historia específica si lo desean.

Estos son los estilos nuevos para agregar a 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;
}

La barra de progreso navegación están listas. A continuación, agreguemos algo novedoso.

Para implementar la opción deslizar, usaremos la biblioteca de control de gestos Hammer.js. Hammer detecta gestos especiales, como desplazamientos y envíos de eventos con información relevante, como delta X que podemos procesar.

Aquí te mostramos cómo podemos usar Hammer para detectar desplazamientos y actualizar automáticamente nuestro elemento cada vez que esto ocurre:

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

El constructor de una clase de LitElement es otro excelente lugar para adjuntar objetos de escucha de eventos en el elemento propio del host. El constructor de Hammer toma un elemento para detectar los gestos. En nuestro caso, es el StoryViewer en sí o this. Luego, mediante la API de Hammer, le indicamos que detecte el gesto de "desplazamiento" y que coloquemos la información de desplazamiento en una nueva propiedad _panData.

Al decorar la propiedad _panData con @internalProperty, LitElement notará los cambios en _panData y realizará una actualización, pero la propiedad NO se verá reflejada en un atributo.

A continuación, aumentemos la lógica de update para usar los datos de desplazamiento:

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

Podemos arrastrar las tarjetas de historias hacia adelante y hacia atrás. Para hacer todo más fluido, regresemos a static get styles y agreguemos transition: transform 0.35s ease-out; al selector ::slotted(*):

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

Ahora podemos deslizar nuestra historia sin problemas:

Cambia de una tarjeta a otra con solo deslizar el dedo

La última característica que agregaremos será la reproducción automática de videos. Cuando una tarjeta de historias entra en foco, queremos ver el video en segundo plano, si existe. Cuando una tarjeta de historias deja el foco, debemos pausar su video.

Implementaremos esto mediante el envío de eventos personalizados “enter” y “exited” en los campos hijos correspondientes cuando cambie el índice. En StoryCard, recibiremos estos eventos y podemos reproducir y pausar cualquier video existente. ¿Por qué elegir el envío de eventos en los elementos hijos en lugar de llamar a los métodos de instancia "entered" y "exited" definidos en story-card? Con los métodos, los usuarios de componentes no tendrían otra opción más que escribir un elemento personalizado si quisieran hacer sus propias tarjetas de historias con animaciones personalizadas. Con los eventos, pueden simplemente adjuntar un objeto de escucha de eventos.

Refactoricemos la propiedad index de StoryViewer para usar un método set, que proporciona una ruta de código conveniente para el envío de eventos:

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

Para finalizar la función de reproducción automática, agregaremos los objetos de escucha de eventos "entered" y "exited" del constructor StoryCard, que reproducen y pausan el video.

Recuerda que el usuario del componente puede o no darle al elemento <story-card> un elemento de video en el slot multimedia. Es posible que no proporcionen un elemento en el slot multimedia. Se debe tener cuidado de no llamar a play en un valor img o null.

En story-card.ts, agrega lo siguiente:

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

La reproducción automática está lista. ✅

Ahora que ya tenemos las funciones esenciales, podemos agregar una más: un efecto increíble de escalamiento. Regresemos una vez más al método update de StoryViewer. Se realizan algunos cálculos matemáticos para obtener el valor de la constante scale. Será igual a 1.0 para el hijo activo o será minScale, en el caso contrario, interpolando también entre dos valores.

Cambia el bucle del método update en story-viewer.ts para que sea como se observa a continuación:

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})`;
  });
  // ...
}

¡Eso es todo, amigos! En este codelab abarcamos mucho, como algunas funciones de LitElement y lit-html, elementos de slot de HTML y control de gestos.

Para obtener una versión completa de este componente, ve a https://github.com/PolymerLabs/story-viewer.