Codelab: Compila un componente de historias

En este codelab, aprenderás a crear una experiencia como las Historias de Instagram en la Web. Construiremos el componente a medida que avancemos, comenzando con HTML, luego CSS y, finalmente, JavaScript.

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

Configuración

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

HTML

Siempre intento usar HTML semántico. Dado que cada amigo puede tener cualquier cantidad de historias, pensé que sería conveniente usar un elemento <section> para cada amigo y un elemento <article> para cada historia. Pero 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 a los 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 ayudar a crear prototipos de historias.
  • El atributo style en cada <article> forma 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 aplicarle diseño. Convirtamos esos esqueletos en algo con lo que las personas querrán interactuar. Hoy trabajaremos en la versión para dispositivos móviles.

.stories

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

  • Cómo hacer que el contenedor sea una cuadrícula
  • Cómo configurar cada elemento secundario para que ocupe el segmento de fila
  • Hacer que el ancho de cada elemento secundario sea el ancho de la ventana de visualización de un dispositivo móvil

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

Chrome y DevTools se abren con una vista de cuadrícula que muestra el diseño de ancho completo.
Herramientas para desarrolladores de Chrome que muestran el desbordamiento de la columna 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á de la ventana de visualización, es hora de indicarle al contenedor cómo manejarlo. 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 que el desplazamiento sea horizontal, por lo que configuraremos overflow-x como auto. Cuando el usuario se desplaza, 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 del CSS y overscroll-behavior de mi entrada de blog.

Tanto el contenedor superior como los secundarios deben aceptar el ajuste del 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 en el siguiente video se 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 está inhabilitada, el navegador usa su comportamiento de desplazamiento predeterminado.

De esta manera, podrás 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 ordene esos elementos secundarios de la historia. Para resolver este problema, usaremos un truco de apilamiento útil. En esencia, estamos creando una cuadrícula de 1 × 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 dará como resultado 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 posicionamiento absoluto, números de punto flotante ni otras directivas de diseño que sacan un elemento del flujo, seguimos en el flujo. Además, es como si no hubiera código, ¡mira! Esto se explica con más detalle en el video y en la entrada de blog.

.story

Ahora solo tenemos que aplicar 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 marcador 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 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 en 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 terminado 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 porque nuestra imagen lo ocupará. Definir 2 imágenes de fondo nos permite usar un truco web de CSS llamado lápida de carga:

  • La imagen de fondo 1 (var(--bg)) es la URL que pasamos 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 termine de descargar.

A continuación, agregaremos un poco de CSS para quitar algunos comportamientos, lo que liberará al navegador para que se mueva 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 por accidente.
  • 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 las 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 una historia que necesite una salida. Obtuve la función de suavización personalizada (cubic-bezier(0.4, 0.0, 1,1)) de la guía de suavización de Material Design (desplázate hasta la sección Suavización acelerada).

Si tienes un buen ojo, es probable que hayas notado la declaración pointer-events: none y te estés rascando la cabeza en este momento. Diría que esta es la única desventaja de la solución hasta ahora. Necesitamos esto porque un elemento .seen.story estará en la parte superior y recibirá toques, aunque sea invisible. Si configuramos pointer-events en none, convertimos la historia de vidrio en una ventana y no robamos más interacciones del usuario. No es una mala compensación, ni muy 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 la esquina superior derecha para avanzar y la esquina superior izquierda para retroceder. Las tareas simples para los usuarios suelen ser un trabajo difícil para los desarrolladores. Sin embargo, nos encargaremos de muchos de ellos.

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 está el medio de nuestro elemento, de modo que podamos decidir si un toque debe avanzar o retroceder.

Estado

A continuación, creamos un objeto pequeño con un estado relevante para nuestra lógica. En este caso, solo nos interesa la historia actual. En nuestro marcado HTML, podemos acceder a él si tomamos 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 está en un elemento <article>, salimos y no hacemos nada. Si es un artículo, tomamos la posición horizontal del mouse o del dedo con clientX. Aún no implementamos navigateStories, pero el argumento que toma especifica en qué dirección debemos ir. Si esa 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 famosos. Parece complicado, pero creo que, si lo tomas línea por línea, te darás cuenta de que es bastante fácil de entender.

Al principio, ocultamos algunos selectores que nos ayudan a decidir si desplazarnos a un amigo o mostrar o ocultar una historia. Como estamos trabajando en el código HTML, lo consultaremos para detectar 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 un amigo diferente?". Para ello, usé la estructura de árbol que construimos y me comuniqué con los padres y 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 anterior o siguiente, muéstrala
    • Si es la primera o la última historia del amigo, muestra un amigo nuevo.
    • Si no hay una historia que seguir en esa dirección, no hagas nada.
  • Guarda 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. Luego, presiona Pantalla completa pantalla completa.

Conclusión

Eso es todo sobre las necesidades que tenía con el componente. No dudes en basarte en él, impulsarlo con datos y, en general, personalizarlo.