Criar um componente de story com um elemento lit

Atualmente, as stories são um componente de IU muito usado. Os apps de redes sociais e de notícias estão integrando-as aos feeds. Neste codelab, criaremos um componente de story com o elemento lit e com TypeScript.

No final, o componente terá esta aparência:

Um componente completo de visualizador de stories exibindo três imagens de café

Uma story de redes sociais ou notícias pode ser vista como uma coleção de cards que serão mostrados em sequência, como uma apresentação de slides. Na verdade, as stories são, literalmente, apresentações de slides. Os cards costumam ter uma imagem ou um vídeo de reprodução automática em destaque e podem ter um texto extra sobre eles. Veja o que criaremos:

Lista de recursos

  • Cards com uma imagem ou um vídeo no plano de fundo
  • Gesto de deslizar para a esquerda ou direita para navegar pela story
  • Vídeos com reprodução automática
  • Função para adicionar texto ou personalizar os cards de outra forma

Pensando na experiência do desenvolvedor desse componente, é recomendável especificar os cards de story em uma marcação HTML simples, como esta:

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

Então vamos adicionar isso à lista de recursos.

Lista de recursos

  • Aceitar vários cards em uma marcação HTML

Assim, qualquer pessoa pode usar o componente de story simplesmente programando em HTML. Isso é ótimo para programadores e não programadores, além de funcionar em qualquer lugar compatível com HTML: sistemas de gerenciamento de conteúdo, frameworks etc.

Pré-requisitos

  • Um shell em que seja possível executar git e npm
  • Um editor de texto

Comece clonando este repositório: story-viewer-starter (link em inglês)

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

O ambiente já está configurado com o elemento lit e com TypeScript. Basta instalar as dependências:

npm i

Usuários do VS Code precisam instalar a extensão lit-plugin (link em inglês) para ter preenchimento automático, verificação de tipo e inspeção de modelos lit-html.

Inicie o ambiente para desenvolvedores executando o seguinte:

npm run dev

Você já pode começar a programar.

Ao criar componentes compostos, às vezes é mais fácil começar com os subcomponentes mais simples e ir avançando. Então, vamos começar criando o <story-card>. Esse componente permite a exibição de um vídeo ou uma imagem sem margens. Os usuários poderão personalizá-lo ainda mais, por exemplo, com textos sobrepostos.

A primeira etapa é definir a classe do componente, que estende o LitElement. O decorador de customElement cuida do registro do elemento personalizado. Agora, ative os decoradores em tsconfig (link em inglês) com a sinalização experimentalDecorators. Se você está usando o repositório de inicialização, eles já estão ativados.

Coloque o seguinte código em story-card.ts:

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

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

Agora o <story-card> é um elemento personalizado utilizável, mas ainda não há nada para exibir. Para definir a estrutura interna do elemento, defina o método render da instância. É aqui que forneceremos o modelo do elemento, usando a tag html de lit-html.

O que precisa ser incluído no modelo desse componente? O usuário precisará fornecer dois itens: um elemento de mídia e uma sobreposição. Então, vamos adicionar um <slot> para cada um deles.

Os slots são usados para especificar como os filhos de um elemento personalizado devem ser renderizados. Para mais informações, veja este ótimo tutorial sobre como usar 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 o elemento de mídia no próprio slot nos ajuda a direcionar esse elemento para, por exemplo, a adição de estilos sem margens e a reprodução automática de vídeos. Coloque o segundo slot (para sobreposições personalizadas) dentro de um elemento de contêiner para podermos fornecer um padding padrão mais tarde.

O componente <story-card> agora pode ser usado desta forma:

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

Mas a aparência é péssima:

um visualizador de story sem estilo exibindo uma foto de café

Como adicionar estilo

Vamos adicionar um pouco de estilo. Com o elemento lit, fazemos isso definindo uma propriedade styles estática e retornando uma string de modelo marcada com css. Qualquer CSS escrito aqui se aplica somente ao nosso elemento personalizado. O CSS com shadow DOM é muito útil para isso.

Vamos definir o estilo do elemento de mídia com slots para cobrir o <story-card>. Enquanto estamos aqui, podemos incluir uma boa formatação para os elementos no segundo slot. Dessa forma, os usuários do componente podem aplicar algumas tags <h1>, <p> ou qualquer outra e ter uma boa exibição por padrão.

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

um visualizador de story com estilo exibindo uma foto de café

Agora, temos cards de story com mídia no plano de fundo e podemos colocar o que quisermos sobre ela. Muito bem! Voltaremos à classe StoryCard em breve para implementar os vídeos com reprodução automática.

Nosso elemento <story-viewer> é o pai dos <story-card>s. Ele será responsável por organizar os cards na horizontal e permitir o gesto de deslizar entre eles. Começaremos da mesma forma que fizemos com a classe StoryCard. Queremos adicionar cards de story como filhos do elemento <story-viewer>, então adicionamos um slot para esses filhos.

Coloque o seguinte código em story-viewer.ts:

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

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

Agora, faremos o layout horizontal. Podemos fazer isso fornecendo todos os posicionamentos absolutos dos <story-card>s com slots e convertendo-os de acordo com o índice. Podemos direcionar o elemento <story-viewer> em si usando o seletor :host.

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

O usuário pode controlar o tamanho dos cards de story substituindo externamente a altura e a largura padrão no host da seguinte forma:

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

Para monitorar o card que está sendo visualizado no momento, vamos adicionar uma variável index da instância à classe StoryViewer. Decorá-la com a @property da classe LitElement fará com que o componente seja renderizado novamente sempre que o valor mudar.

import { property } from 'lit-element';

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

Cada card precisa ser convertido para a posição horizontal. Vamos aplicar essas conversões no método update do ciclo de vida (link em inglês) do elemento lit. O método de atualização será executado sempre que uma propriedade observada desse componente mudar. Normalmente, consultamos o slot e retornamos slot.assignedElements(). No entanto, como temos apenas um slot sem nome, isso equivale a usar this.children. Para facilitar, use 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);
  }
}

Agora, os <story-card>s estão todos em sequência. Isso ainda funciona com outros elementos como filhos, desde que tenhamos cuidado para definir o estilo de maneira adequada:

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

Acesse build/index.html e remova a marca de comentário para o restante dos elementos do card de story. Agora, vamos fazer isso para navegar até eles.

A seguir, adicionaremos uma forma de navegar entre os cards e uma barra de progresso.

Vamos adicionar algumas funções auxiliares à StoryViewer para navegar pela story. Elas definirão um índice enquanto o associam a um intervalo válido.

Em story-viewer.ts, na classe StoryViewer, adicione:

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

Para expor a navegação para o usuário final, adicionaremos os botões "previous" (anterior) e "next" (próxima) ao <story-viewer>. Quando houver um clique em um dos botões, precisamos chamar a função auxiliar next ou previous. O lit-html facilita a adição de listeners de eventos a elementos. Podemos renderizar os botões e adicionar um listener de cliques ao mesmo tempo.

Atualize o método render para o seguinte:

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

Confira como podemos adicionar listeners de eventos in-line aos novos botões SVG, diretamente no método render. Isso funciona para qualquer evento. Basta adicionar uma vinculação no formato @eventname=${handler} a um elemento.

Adicione o seguinte à propriedade static styles para definir o estilo dos botões:

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

Para a barra de progresso, usaremos a grade CSS para definir o estilo de pequenas caixas, uma para cada card de story. Podemos usar a propriedade index para adicionar classes condicionalmente às caixas para indicar se elas foram "vistas" ou não. Poderíamos usar uma expressão condicional como i <= this.index : 'watched': '', mas talvez isso gerasse um nível de detalhes elevado se adicionássemos mais classes. Felizmente, o lit-html usa uma diretiva chamada classMap para ajudar. Primeiro, importe a classMap:

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

Depois, adicione a seguinte marcação à parte inferior do 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>

Também acrescentamos mais gerenciadores de cliques para que os usuários possam acessar diretamente um card de story específico se quiserem.

Estes são os novos estilos para adicionar à propriedade 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;
}

Navegação e barra de progresso concluídas. Agora vamos incrementar um pouco.

Para implementar os gestos de deslizar, usaremos a biblioteca de controle de gestos Hammer.js (link em inglês). A Hammer detecta gestos especiais, como movimentos, e envia eventos com informações relevantes (como delta X) que podemos consumir.

Veja como podemos usar a Hammer para detectar movimentos e atualizar automaticamente nosso elemento sempre que ocorrer um evento de movimentação:

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

O construtor de uma classe LitElement é outro excelente local para anexar listeners de eventos ao elemento host propriamente dito. O construtor da Hammer usa um elemento para detectar gestos. No nosso caso, é a própria StoryViewer, ou this. Em seguida, usando a API da Hammer, solicitamos a detecção do gesto de "movimento" e definimos as informações da movimentação em uma nova propriedade _panData.

Ao decorar a propriedade _panData com @internalProperty, a LitElement observará as mudanças em _panData e realizará uma atualização, mas a propriedade NÃO será refletida em um atributo.

Vamos aumentar a lógica de update para usar os dados de movimentação:

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

Agora, é possível arrastar os cards de story para frente e para trás. Para facilitar, volte para static get styles e adicione transition: transform 0.35s ease-out; ao seletor ::slotted(*):

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

Finalmente, temos o recurso para deslizar com facilidade:

Navegação entre cards de story com gesto de deslizar fácil

O último recurso que acrescentaremos é a adição de vídeos com reprodução automática. Quando um card de story entra no foco, queremos que o vídeo no plano de fundo seja iniciado, se houver. Quando um card de story sai do foco, precisamos pausar o vídeo.

Vamos implementar isso enviando eventos personalizados de "entrada" e "saída" nos filhos apropriados sempre que o índice mudar. Na classe StoryCard, receberemos esses eventos e reproduziremos ou pausaremos os vídeos existentes. Por que escolher enviar eventos nos filhos em vez de chamar métodos de "entrada" e "saída" da instância definidos na StoryCard? Com os métodos, os usuários do componente não teriam a opção de escrever um elemento personalizado se quisessem criar o próprio card de story com animações personalizadas. Com os eventos, eles podem simplesmente anexar um listener de eventos.

Vamos refatorar a propriedade index da StoryViewer para usar um setter, que fornece um caminho de código conveniente para enviar os 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 encerrar o recurso de reprodução automática, adicionaremos listeners de eventos à "entrada" e "saída" no construtor da StoryCard que iniciam e pausam o vídeo.

Lembre-se de que o usuário do componente pode ou não dar ao <story-card> um elemento de vídeo no slot de mídia. Talvez ele nem mesmo forneça um elemento no slot de mídia. Precisamos tomar cuidado para não chamar play em uma imagem ou em um nulo.

De volta a story-card.ts, adicione o seguinte:

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

Reprodução automática concluída. ✅

Agora que temos todos os recursos essenciais, vamos adicionar um último: um belo efeito de dimensionamento. Vamos voltar mais uma vez ao método update da StoryViewer. Algumas operações matemáticas são feitas para buscar o valor na constante scale. Ele será igual a 1.0 para o filho ativo e minScale em outros casos, havendo interpolação entre esses dois valores.

Mude a repetição no método update em story-viewer.ts para que ela seja:

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

Isso é tudo. Nesta postagem, falamos sobre muitas coisas, inclusive sobre alguns recursos de LitElement e lit-html, elementos de slot HTML e controle por gestos.

Para ver uma versão completa desse componente, acesse https://github.com/PolymerLabs/story-viewer (link em inglês).