Codelab: como criar um componente de Histórias

Este codelab ensina como criar uma experiência como os Stories do Instagram na Web. Criaremos o componente conforme avançamos, começando com HTML, depois CSS, e, em seguida, JavaScript.

Confira a postagem do blog Como criar um componente de Histórias para saber mais sobre as melhorias progressivas feitas ao criar esse componente.

Configuração

  1. Clique em Remixar para editar para tornar o projeto editável.
  2. Abra app/index.html.

HTML

Meu objetivo sempre é usar o HTML semântico. Como cada amigo pode ter inúmeras histórias, achei que seria significativo usar uma Elemento <section> para cada amigo e um elemento <article> para cada história. Vamos começar do início. Primeiro, precisamos de um contêiner para de matérias.

Adicione um elemento <div> ao seu <body>:

<div class="stories">

</div>

Adicione alguns elementos <section> para representar amigos:

<div class="stories">
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
</div>

Adicione alguns elementos <article> para representar histórias:

<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>
  • Estamos usando um serviço de imagem (picsum.com) para ajudar a criar protótipos de histórias.
  • O atributo style em cada <article> faz parte do carregamento de um marcador de posição sobre o qual você aprenderá mais na próxima seção.

CSS

Nosso conteúdo está pronto para o estilo. Vamos transformar esses ossos em algo que as pessoas vão querem interagir. Trabalharemos com foco em dispositivos móveis hoje.

.stories

Para o contêiner <div class="stories">, queremos um contêiner de rolagem horizontal. Podemos fazer isso:

  • Transformar o contêiner em uma grade
  • Configurar cada filho para preencher a faixa de linha
  • Tornar a largura de cada filho a largura de uma janela de visualização de dispositivo móvel

A grade vai continuar posicionando as novas colunas de largura 100vw à direita da anterior um, até colocar todos os elementos HTML em sua marcação.

Chrome e DevTools são abertos com um visual em grade mostrando o layout em largura total
Chrome DevTools mostrando o estouro da coluna da grade, criando um botão de rolagem horizontal.

Adicione o seguinte CSS à parte inferior de app/css/index.css:

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
}

Agora que o conteúdo se estende além da janela de visualização, é hora de dizer que contêiner sobre como lidar com isso. Adicione as linhas de código destacadas ao seu conjunto de regras .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;
}

Queremos rolagem horizontal, então vamos definir overflow-x como auto. Quando o usuário rola a página, queremos que o componente descanse suavemente sobre a próxima história, então usaremos scroll-snap-type: x mandatory. Leia mais sobre o assunto CSS nos pontos de ajuste de rolagem do CSS e overscroll-behavior seções de minha postagem do blog.

É preciso que o contêiner pai e os filhos concordem com o ajuste de rolagem. Portanto, vamos lidar com isso agora. Adicione o seguinte código à parte de baixo de app/css/index.css:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

Seu app ainda não funciona, mas o vídeo abaixo mostra o que acontece quando scroll-snap-type está ativado e desativado. Quando ativados, cada tamanho rolar para a próxima matéria. Quando desativado, o navegador usa seu comportamento de rolagem padrão.

Isso fará com que você role a página entre seus amigos, mas ainda temos um problema com histórias para resolver.

.user

Vamos criar um layout na seção .user para organizar a história filha no lugar. Usaremos um truque de empilhamento útil para resolver isso. Essencialmente, estamos criando uma grade 1x1 onde a linha e a coluna têm a mesma grade alias de [story], e cada item da grade de matérias vai tentar reivindicar esse espaço, resultando em uma pilha.

Adicione o código destacado ao conjunto de regras .user:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  display: grid;
  grid: [story] 1fr / [story] 1fr;
}

Adicione o seguinte conjunto de regras à parte inferior de app/css/index.css:

.story {
  grid-area: story;
}

Agora, sem posicionamento absoluto, flutuações ou outras diretivas de layout que tomam um elemento fora do fluxo, ainda estamos no fluxo. Além disso, quase nenhum código, olha isso! Isso é detalhado no vídeo e na postagem do blog em mais detalhes.

.story

Agora, só precisamos estilizar o item da história em si.

Anteriormente, mencionamos que o atributo style em cada elemento <article> faz parte de um de carregamento de marcador de posição:

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

Vamos usar a propriedade background-image do CSS, que permite especificar mais de uma imagem de plano de fundo. Podemos colocá-los em ordem para que nosso usuário imagem fica na parte superior e aparecerá automaticamente quando o carregamento for concluído. Para ativar essa opção, colocaremos nosso URL de imagem em uma propriedade personalizada (--bg) e a usaremos no CSS para adicionar o marcador de posição de carregamento.

Primeiro, vamos atualizar o conjunto de regras .story para substituir um gradiente por uma imagem de plano de fundo. assim que o carregamento for concluído. Adicione o código destacado ao conjunto de regras .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}

Definir background-size como cover garante que não haja espaços vazios na janela de visualização, pois nossa imagem a preencherá. Definir duas imagens de plano de fundo nos permite realizar um truque interessante na Web do CSS chamado tombstone de carregamento:

  • A imagem de plano de fundo 1 (var(--bg)) é o URL transmitido inline no HTML
  • Imagem de plano de fundo 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) é um gradiente aparecem durante o carregamento do URL

O CSS substituirá automaticamente o gradiente pela imagem assim que o download da imagem for concluído.

Em seguida, adicionaremos CSS para remover comportamentos, liberando o navegador para que ele seja mais rápido. Adicione o código destacado ao conjunto de regras .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 impede que os usuários selecionem texto acidentalmente
  • O touch-action: manipulation instrui o navegador que essas interações devem ser tratados como eventos de toque, o que libera o navegador de tentar decidir se você está clicando em um URL ou não

Por fim, vamos adicionar um pouco de CSS para animar a transição entre as histórias. Adicione o método código destacado ao conjunto de regras .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;
  }
}

A classe .seen será adicionada a uma história que precisa de uma saída. Recebi a função de easing personalizado (cubic-bezier(0.4, 0.0, 1,1)) do Easing do Material Design (role até a seção easing acelerado).

Se você tem interesse, provavelmente notou o pointer-events: none e estamos inovando. Eu diria que esta é a única e a desvantagem da solução até o momento. Precisamos disso porque um elemento .seen.story fica no topo e recebe toques, mesmo que esteja invisível. Ao definir o pointer-events a none, transformamos a história de vidro em uma janela e roubamos mais interações do usuário. Nada mau para a escolha, não é muito difícil de gerenciar aqui no nosso CSS agora. Não estamos fazendo malabarismos com z-index. Estou bem com isso imóvel.

JavaScript

As interações de um componente de Histórias são bastante simples para o usuário: toque no para a direita para avançar, toque na esquerda para voltar. Coisas simples para os usuários trabalho duro para os desenvolvedores. Mas nós cuidaremos de muitos deles.

Configuração

Para começar, vamos computar e armazenar o máximo possível de informações. Adicione o código a seguir a app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

Nossa primeira linha de JavaScript captura e armazena uma referência ao código HTML principal raiz do elemento. A próxima linha calcula onde está o meio do nosso elemento, então pode decidir se um toque é para avançar ou retroceder.

Estado

Em seguida, criamos um pequeno objeto com algum estado relevante para nossa lógica. Neste nesse caso, só estamos interessados na história atual. Em nossa marcação HTML, podemos acessá-la capturando o primeiro amigo e a história mais recente dele. Adicione o código destacado ao seu app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

Listeners

Temos lógica suficiente agora para começar a detectar eventos de usuários e direcioná-los.

Camundongo

Para começar, vamos detectar o evento 'click' no contêiner de histórias. Adicione o código destacado a 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')
})

Se um clique acontece e não está em um elemento <article>, ele é salvo e não faz nada. Se for um artigo, seguramos a posição horizontal do mouse ou dedo com clientX: Ainda não implementamos navigateStories, mas o argumento de que ela especifica para onde precisamos ir. Se essa posição do usuário for maior que a mediana, sabemos que precisamos navegar até next. Caso contrário, prev (anterior).

Teclado

Agora, vamos ouvir pressionamentos de teclado. Se a seta para baixo for pressionada, navegaremos para next. Se for a seta para cima, vamos para prev.

Adicione o código destacado a 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')
})

Navegação das Histórias

É hora de abordar a lógica de negócios única das histórias e a UX em que elas se desenvolveram é conhecida. Isso parece complicado e grosseiro, mas acho que, se você prestar atenção ela será bastante compreensível.

De antemão, guardamos alguns seletores que nos ajudam a decidir se devemos rolar para uma amigo ou mostrar/ocultar uma história. Como o HTML é onde estamos trabalhando, consultando a presença de amigos (usuários) ou histórias (história).

Essas variáveis nos ajudarão a responder perguntas como “Dada história x, “a seguir” ou passar para outra história com esse mesmo amigo ou com um amigo diferente?" Fiz isso usando a árvore estrutura que construímos, alcançando os pais e seus filhos.

Adicione o seguinte código à parte de baixo 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
}

Aqui está nossa meta de lógica de negócios, o mais próximo possível da linguagem natural:

  • Decida como lidar com o toque
    • Se houver uma matéria seguinte/anterior: mostre a história
    • Se for a última ou a primeira história do amigo: mostre a um novo amigo
    • Se não houver história para ir nessa direção: não faça nada.
  • Guardar a nova story atual em state

Adicione o código destacado à função 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
    }
  }
}

Faça um teste

  • Para visualizar o site, pressione Ver app. Em seguida, pressione Tela cheia tela cheia

Conclusão

Esse é o resumo das necessidades que eu tinha com o componente. Sinta-se à vontade para complementar com dados e, em geral, com a sua cara.