Codelab: Compila un componente de historias

En este codelab, aprenderás a crear una experiencia similar a las Historias de Instagram en la Web. Compilaremos el componente a medida que avancemos, comenzando con HTML, luego CSS y, por último, JavaScript.

Consulta mi entrada de blog Cómo crear un componente de Historias para obtener información sobre las mejoras progresivas que se realizaron durante la creación de este componente.

Configuración

  1. Haz clic en Remix to Edit para que el proyecto se pueda editar.
  2. Abre app/index.html.

HTML

Siempre intento usar HTML semántico. Como cada amigo puede tener cualquier cantidad de historias, pensé que era significativo usar un elemento <section> para cada amigo y un elemento <article> para cada historia. Sin embargo, comencemos por 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>
  • Usamos un servicio de imágenes (picsum.com) para crear prototipos de historias.
  • El atributo style en cada <article> forma parte de una técnica de carga de marcadores de posición, sobre la que obtendrás más información en la siguiente sección.

CSS

Nuestro contenido está listo para aplicarle un estilo. Convirtamos esos huesos en algo con lo que la gente querrá interactuar. Hoy trabajaremos con el enfoque de dispositivos móviles primero.

.stories

Para nuestro contenedor <div class="stories">, queremos un contenedor de desplazamiento horizontal. Para ello, podemos hacer lo siguiente:

  • Cómo convertir el contenedor en una cuadrícula
  • Configurar cada hijo para que complete el segmento de la fila
  • Hacer que el ancho de cada elemento secundario sea el ancho de la ventana gráfica de un dispositivo móvil

La cuadrícula seguirá colocando nuevas columnas de 100vw de ancho a la derecha de la anterior hasta que haya colocado todos los elementos HTML en tu marcado.

Chrome y las Herramientas para desarrolladores se abren con un elemento visual de cuadrícula que muestra el diseño de ancho completo.
Las Herramientas para desarrolladores de Chrome muestran el desbordamiento de la columna de la cuadrícula, lo que genera un desplazamiento horizontal.

Agrega el siguiente código 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 a ese contenedor cómo controlarlo. Agrega las líneas de código destacadas a tu conjunto de reglas de .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 desplazamiento horizontal, por lo que estableceremos overflow-x en auto. Cuando el usuario se desplace, queremos que el componente se detenga suavemente en el siguiente cuento, por lo que usaremos scroll-snap-type: x mandatory. Obtén más información sobre este CSS en las secciones CSS Scroll Snap Points y overscroll-behavior de mi entrada de blog.

Tanto el contenedor principal como los elementos secundarios deben aceptar el ajuste de desplazamiento, así que vamos a controlar 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 el siguiente video muestra lo que sucede cuando se habilita y se inhabilita scroll-snap-type. Cuando está habilitada, cada desplazamiento horizontal se ajusta a la siguiente historia. Cuando se inhabilita, el navegador usa su comportamiento de desplazamiento predeterminado.

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

.user

Creemos un diseño en la sección .user que coloque esos elementos secundarios de la historia en su lugar. Usaremos un truco de apilamiento práctico para resolver este problema. Básicamente, creamos una cuadrícula de 1 x 1 en la que la fila y la columna tienen el mismo alias de cuadrícula [story], y cada elemento de la cuadrícula de historias intentará reclamar ese espacio, lo que generará una pila.

Agrega el código destacado a tu conjunto de reglas de .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 posicionamiento absoluto, flotantes ni otras directivas de diseño que saquen un elemento del flujo, seguimos en el flujo. Además, casi no hay código. ¡Mira eso! En el video y la entrada de blog, se desglosa este tema con más detalle.

.story

Ahora solo tenemos que aplicar el diseño al elemento de la historia.

Anteriormente, mencionamos que el atributo style en cada elemento <article> forma 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 de modo que la foto del 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 dentro de nuestro CSS para superponerla con 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 haya cargado. Agrega el código destacado a tu conjunto de reglas de .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 la ventana gráfica, ya que nuestra imagen la llenará. Definir 2 imágenes de fondo nos permite usar un truco web de CSS ingenioso llamado lápida de carga:

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

El CSS reemplazará automáticamente el gradiente por la imagen una vez que se complete la descarga.

A continuación, agregaremos algo de CSS para quitar cierto comportamiento y liberar el navegador para que se mueva más rápido. Agrega el código destacado a tu conjunto de reglas de .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 por accidente
  • touch-action: manipulation le indica al navegador que estas interacciones se deben tratar como eventos táctiles, lo que libera al navegador de intentar decidir si estás haciendo 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;
  }
}

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

Si tienes buen ojo, probablemente notaste la declaración pointer-events: none y ahora te estás preguntando qué significa. Diría que este es el único inconveniente de la solución hasta el momento. Necesitamos esto porque un elemento .seen.story estará en la parte superior y recibirá toques, aunque sea invisible. Si establecemos pointer-events en none, convertimos el cuento de vidrio en una ventana y ya no robamos más interacciones del usuario. No es una mala compensación, no es demasiado difícil de administrar en nuestro CSS en este momento. No estamos haciendo malabares con z-index. Aún me siento bien con esto.

JavaScript

Las interacciones de un componente de Historias son bastante simples para el usuario: presiona a la derecha para avanzar y a la izquierda para retroceder. Las cosas simples para los usuarios suelen ser un trabajo arduo para los desarrolladores. Sin embargo, nos encargaremos de gran parte de ello.

Configuración

Para comenzar, calculemos 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)

Nuestra primera línea de JavaScript toma 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 para avanzar o retroceder.

Estado

A continuación, creamos un objeto pequeño con algún estado relevante para nuestra lógica. En este caso, solo nos interesa el cuento 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 del usuario y dirigirlos.

Ratón

Comencemos por escuchar 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 es en un elemento <article>, salimos y no hacemos nada. Si se trata de un artículo, tomamos la posición horizontal del mouse o el dedo con clientX. Aún no implementamos navigateStories, pero el argumento que toma especifica en qué dirección debemos ir. Si la posición del usuario es mayor que la mediana, sabemos que debemos navegar a next; de lo contrario, a prev (anterior).

Teclado

Ahora, escuchemos las pulsaciones del teclado. Si se presiona la flecha hacia abajo, navegamos a next. Si es la flecha hacia arriba, vamos 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 de Historias

Es hora de abordar la lógica empresarial única de las historias y la UX por la que se hicieron famosas. Parece complicado, pero creo que, si lo lees línea por línea, verás que es bastante fácil de entender.

Por adelantado, almacenamos algunos selectores que nos ayudan a decidir si desplazarnos hasta un amigo o mostrar u ocultar una historia. Dado que trabajaremos con HTML, lo consultaremos para saber si hay amigos (usuarios) o historias (story).

Estas variables nos ayudarán a responder preguntas como "dada la historia X, ¿"siguiente" significa pasar a otra historia del mismo amigo o a una de un amigo diferente?". Lo hice usando la estructura de árbol que creamos, llegando a las madres y 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 cercano posible al lenguaje natural:

  • Decide cómo controlar el toque.
    • Si hay una historia siguiente o anterior, mostrarla
    • Si es la última o la primera historia del amigo, muestra un amigo nuevo.
    • Si no hay un cuento al que ir en esa dirección, no hagas nada.
  • Almacena la nueva historia actual en state

Agrega el código destacado a tu 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 y, luego, Pantalla completa pantalla completa.

Conclusión

Eso es todo lo que necesitaba del componente. No dudes en ampliarlo, usarlo con datos y, en general, hacerlo tuyo.