Codelab: Compila un componente de historias

En este codelab, aprenderás a crear una experiencia como las historias de Instagram en la Web. Compilaremos el componente sobre la marcha. Comenzaremos con HTML, luego CSS y, por último, JavaScript.

Consulta mi entrada de blog sobre cómo crear un componente de historias para obtener más información sobre las mejoras progresivas que se realizaron durante la compilación de este componente.

Configuración

  1. Haz clic en Remix to Edit para que el proyecto sea editable.
  2. Abre app/index.html.

HTML

Siempre trato de usar HTML semántico. Como cada amigo puede tener cualquier cantidad de historias, me pareció significativo usar un elemento <section> para cada amigo y un elemento <article> para cada historia. Sin embargo, comencemos desde el principio. Primero, necesitamos un contenedor para nuestro componente de historias.

Agrega un elemento <div> a tu <body>:

<div class="stories">

</div>

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

Agrega algunos elementos <article> para representar historias:

<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 un servicio de imágenes (picsum.com) para ayudar a crear prototipos de historias.
  • El atributo style en cada <article> es parte de una técnica de carga de marcador de posición, sobre la que obtendrás más información en la siguiente sección.

CSS

Nuestro contenido está listo para el estilo. Vamos a convertir esos huesos en algo con lo que la gente querrá interactuar. Hoy trabajaremos priorizando los dispositivos móviles.

.stories

Para nuestro contenedor <div class="stories">, queremos un contenedor de desplazamiento horizontal. Podemos lograr esto de las siguientes maneras:

  • Convertir el contenedor en una Grid
  • Configura cada elemento secundario para que complete el seguimiento de filas
  • Cómo hacer que el ancho de cada elemento secundario sea el ancho de un viewport de un dispositivo móvil

La cuadrícula seguirá colocando nuevas columnas de ancho 100vw a la derecha de la anterior, hasta que se coloquen todos los elementos HTML en el lenguaje de marcado.

Chrome y Herramientas para desarrolladores se abren con una imagen de cuadrícula que muestra el diseño de ancho completo
Las Herramientas para desarrolladores de Chrome muestran el desbordamiento de columnas de la cuadrícula, lo que crea un desplazamiento horizontal.

Agrega el siguiente CSS a la parte inferior de app/css/index.css:

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

Ahora que tenemos contenido que se extiende más allá del viewport, es hora de indicarle al contenedor cómo controlarlo. Agrega las líneas de código destacadas a tu conjunto de reglas .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 el desplazamiento horizontal, por lo que estableceremos overflow-x en auto. Cuando el usuario se desplace, queremos que el componente se apoye suavemente en la siguiente historia, por lo que usaremos scroll-snap-type: x mandatory. Obtén más información sobre este CSS en las secciones Puntos de ajuste de desplazamiento de CSS y comportamiento de sobredesplazamiento de la entrada de mi blog.

Se necesita tanto el contenedor superior como los secundarios para aceptar el ajuste de desplazamiento, así que controlemos eso ahora. Agrega el siguiente código al final de app/css/index.css:

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

Tu app aún no funciona, pero en el siguiente video se muestra lo que sucede cuando se habilita scroll-snap-type y se inhabilita. Cuando se habilita, cada desplazamiento horizontal se ajusta a la siguiente historia. Cuando se inhabilita, el navegador utiliza su comportamiento de desplazamiento predeterminado.

Eso te permitirá desplazarte entre tus amigos, pero aún tenemos un problema con las historias por resolver.

.user

Creemos un diseño en la sección .user que convierta esos elementos secundarios de la historia en su lugar. Para resolver esto, usaremos un truco de apilado práctico. En esencia, creamos una cuadrícula de 1 x 1 en la que la fila y la columna tienen el mismo alias de cuadrícula de [story], y cada elemento de la cuadrícula de la historia intentará reclamar ese espacio, lo que generará una pila.

Agrega el código destacado a tu conjunto de reglas .user:

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

Agrega el siguiente conjunto de reglas a la parte inferior de app/css/index.css:

.story {
  grid-area: story;
}

Ahora, sin el posicionamiento absoluto, los números de punto flotante ni otras directivas de diseño que saquen un elemento del flujo, seguimos en el flujo. Además, es casi como cualquier código. ¡Mira eso! Esto se desglosa en el video y en la entrada de blog con más detalle.

.story

Ahora, solo debemos aplicar el estilo al elemento de la historia en sí.

Anteriormente mencionamos que el atributo style en cada elemento <article> es parte de una técnica de carga de marcadores de posición:

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

Usaremos la propiedad background-image de CSS, que nos permite especificar más de una imagen de fondo. Podemos ordenarlas para que la foto de usuario esté en la parte superior y aparezca automáticamente cuando termine de cargarse. Para habilitar esto, colocaremos la URL de nuestra imagen en una propiedad personalizada (--bg) y la usaremos en nuestra CSS para superponer el marcador de posición de carga.

Primero, actualicemos el conjunto de reglas .story para reemplazar un gradiente por una imagen de fondo una vez que se termine de cargar. Agrega el código destacado a tu conjunto de reglas .story:

.story {
  grid-area: story;

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

Establecer background-size en cover garantiza que no haya espacio vacío en el viewport, ya que nuestra imagen lo llenará. Definir 2 imágenes de fondo nos permite extraer un buen truco web de CSS llamado logstone de carga:

  • La imagen de fondo 1 (var(--bg)) es la URL que pasamos intercalada en el HTML
  • Imagen de fondo 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) es un gradiente que se mostrará mientras se carga la URL

CSS reemplazará automáticamente el gradiente por la imagen una vez que la imagen termine de descargarse.

A continuación, agregaremos algunas CSS para quitar ciertos comportamientos, de modo que el navegador pueda moverse más rápido. Agrega el código destacado a tu conjunto de reglas .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 evita que los usuarios seleccionen texto accidentalmente
  • touch-action: manipulation le indica al navegador que estas interacciones deben tratarse como eventos táctiles, lo que libera al navegador de intentar decidir si haces clic en una URL o no.

Por último, agreguemos un poco de CSS para animar la transición entre historias. Agrega el código destacado a tu conjunto de reglas .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;
  }
}

Se agregará la clase .seen a una historia que necesite una salida. Obtuve la función de aceleración personalizada (cubic-bezier(0.4, 0.0, 1,1)) de la guía Aceleración de Material Design (desplázate hasta la sección Aceleración acelerada).

Si tienes muy buen ojo, es probable que hayas notado la declaración pointer-events: none y te estés moviéndote en este momento. Yo diría que esta es la única desventaja de la solución hasta ahora. Lo necesitamos porque un elemento .seen.story estará en la parte superior y recibirá presiones, aunque sea invisible. Cuando se configura el pointer-events en none, convertimos la historia en vidrio en una ventana y no robamos más interacciones del usuario. Esto no es tan malo ni difícil de administrar en nuestro CSS en este momento. No estamos haciendo malabares con z-index. De todos modos, me siento bien.

JavaScript

Las interacciones de un componente de Historias son bastante simples para el usuario: presiona la derecha para avanzar y la izquierda para volver. Las cosas simples para los usuarios tienden a ser un trabajo arduo para los desarrolladores. Sin embargo, nos encargaremos de muchas de ellas.

Configuración

Para comenzar, procesemos y almacenemos la mayor cantidad de información posible. Agrega el siguiente código a app/js/index.js:

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

La primera línea de JavaScript captura y almacena una referencia a la raíz de nuestro elemento HTML principal. La siguiente línea calcula dónde se encuentra el centro de nuestro elemento, de modo que podamos decidir si un toque es avanzar o retroceder.

Estado

A continuación, haremos un objeto pequeño con algún estado relevante para nuestra lógica. En este caso, solo nos interesa la historia actual. En nuestro lenguaje de marcado HTML, podemos acceder a él tomando el primer amigo y su historia más reciente. Agrega el código destacado a tu app/js/index.js:

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

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

Objetos de escucha

Ahora tenemos suficiente lógica para comenzar a escuchar los eventos de usuario y dirigirlos.

Ratón

Para comenzar, escucha el evento 'click' en nuestro contenedor de historias. Agrega el 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')
})

Si se produce un clic y no se encuentra en un elemento <article>, es cautiverio y no hacemos nada. Si se trata de un artículo, tomamos la posición horizontal del mouse o del dedo con clientX. Todavía no implementamos navigateStories, pero el argumento que usa especifica la dirección que debemos ir. Si esa posición del usuario es superior a la mediana, sabemos que debemos navegar a next; de lo contrario, debemos navegar a prev (anterior).

Teclado

Ahora, escuchemos las pulsaciones del teclado. Si presionas la flecha hacia abajo, navegaremos a next. Si es la flecha hacia arriba, iremos a prev.

Agrega el 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')
})

Navegación por las historias

Es momento de abordar la lógica empresarial única de las historias y la UX por la que se hicieron famosas. Esto tiene un aspecto grueso y complicado, pero creo que si lo tomas línea por línea, descubrirás que es fácil de digerir.

Por adelantado, almacenamos algunos selectores que nos ayudan a decidir si desplazarnos a un amigo, o bien mostrar/ocultar una historia. Dado que el código HTML es el lugar donde estamos trabajando, lo consultaremos en busca de la presencia de amigos (usuarios) o historias (historia).

Estas variables nos ayudarán a responder preguntas como: "dada la historia x, ¿"siguiente" significa pasar a otra historia de este mismo amigo o a otro amigo?" Lo hice usando la estructura de árbol que construimos, llegando a los padres y a sus hijos.

Agrega el siguiente código al final 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
}

Este es nuestro objetivo de lógica empresarial, lo más parecido posible al lenguaje natural:

  • Decide cómo controlar el toque
    • Si hay una historia anterior o siguiente: muestra esa historia
    • Si es la primera o la última historia del amigo, muéstrale a un amigo nuevo.
    • Si no hay una historia para ir en esa dirección, no hagas nada
  • Guarda la nueva historia actual en state

Agrega el código destacado a la función 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
    }
  }
}

Probar

  • Para obtener una vista previa del sitio, presiona Ver app. Luego, presiona Pantalla completa pantalla completa.

Conclusión

Este es un resumen de las necesidades que tenía el componente. ¡Siéntete libre de ampliarlo, manejarlo con datos y, en general, personalizarlo!