Codelab: Compila un componente de historias

En este codelab, aprenderás a crear una experiencia como las historias de Instagram. en la Web. Crearemos el componente sobre la marcha, empezando con HTML, luego CSS luego, JavaScript.

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

Configuración

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

HTML

Siempre intento usar HTML semántico. Como cada amigo puede tener muchas historias, pensé que era importante utilizar una <section> para cada amigo y un elemento <article> para cada historia. Empecemos por el principio. Primero, necesitamos un contenedor para nuestro de historias.

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

<div class="stories">

</div>

Agrega algunos elementos <section> para representar a tus 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 las 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>
  • Para crear prototipos de historias, usamos un servicio de imágenes (picsum.com).
  • El atributo style en cada <article> forma parte de la carga de un marcador de posición. de la que obtendrás más información en la siguiente sección.

CSS

Nuestro contenido está preparado para el estilo. Convirtamos esos huesos en algo que hará todo el mundo. con el que quieres interactuar. Hoy estaremos trabajando en la priorización de los dispositivos móviles.

.stories

Para nuestro contenedor <div class="stories">, queremos un contenedor de desplazamiento horizontal. Podemos lograrlo de la siguiente manera:

  • Convertir al contenedor en una cuadrícula
  • Cómo configurar cada elemento secundario para que ocupe la pista de filas
  • Hacer que el ancho de cada elemento secundario sea el ancho del viewport de un dispositivo móvil

La cuadrícula seguirá colocando las nuevas columnas de ancho de 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 cuadrícula visual 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 genera una barra de 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 aclarar que 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 el desplazamiento horizontal, por lo que estableceremos overflow-x en auto Cuando el usuario se desplaza, queremos que el componente descanse suavemente en la siguiente historia, así que usaremos scroll-snap-type: x mandatory. Más información CSS en los puntos de ajuste de desplazamiento de CSS y el comportamiento de sobredesplazamiento de mi entrada de blog.

Se necesita tanto el contenedor superior como los secundarios para aceptar el ajuste de desplazamiento. mandamos 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 scroll-snap-type está habilitado y, además, inhabilitado. Cuando está habilitada, cada horizontal el desplazamiento se pasa a la siguiente historia. Cuando se inhabilita, el navegador utiliza su comportamiento de desplazamiento predeterminado.

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

.user

Creemos un diseño en la sección .user que gestione la historia de ese elemento secundario. los elementos en su lugar. Para resolver esto, usaremos un útil truco de apilado. Básicamente, estamos creando una cuadrícula de 1 x 1 donde la fila y la columna tienen la misma cuadrícula alias de [story], y cada elemento de la cuadrícula de la historia intentará reclamar ese espacio, lo que genera 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 al final 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 lleven un elemento fuera de flujo, seguimos en el flujo. Además, es casi cualquier código, ¡Mira eso! Esta información se desglosa en más detalle en el video y la entrada de blog.

.story

Ahora solo debemos darle estilo al elemento de la historia.

Anteriormente, mencionamos que el atributo style de cada elemento <article> es parte de un 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 ponerlas en un orden para que nuestro usuario imagen está en la parte superior y aparecerá automáticamente cuando termine de cargarse. Para Si habilitamos esta función, colocaremos la URL de nuestra imagen en una propiedad personalizada (--bg) y la usaremos. dentro de nuestro CSS para aplicar capas con el marcador de posición de carga.

Primero, actualicemos el conjunto de reglas .story para reemplazar un gradiente por una imagen de fondo cuando termine de cargarse. 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));
}

Configurar background-size como cover garantiza que no haya espacio vacío en la viewport, porque nuestra imagen lo llenará. Define 2 imágenes de fondo nos permite implementar un interesante truco web de CSS llamado tombstone 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 para mostrar mientras se carga la URL

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

A continuación, agregaremos un poco de CSS para quitar algo de comportamiento, lo que liberará al navegador para poder moverlo 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 error
  • 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 se hace 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 al 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 necesita una salida. Tengo la función de aceleración personalizada (cubic-bezier(0.4, 0.0, 1,1)) de la Aceleración de Material Design (desplázate hasta la sección Aceleración acelerada).

Si tienes un ojo agudo, es probable que hayas notado el pointer-events: none y te estás estrenando la cabeza. Diría que este es el único una desventaja de la solución hasta ahora. Lo necesitamos porque un elemento .seen.story estará en la parte superior y recibirás toques, incluso si es invisible. Si estableces pointer-events a none, convertimos la historia del cristal en una ventana y no robamos más interacciones de los usuarios. No es una desventaja, no es tan difícil de administrar en nuestro CSS ahora mismo. No estamos haciendo malabares con z-index. Me siento bien quieto.

JavaScript

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

Configuración

Para empezar, calculemos y almacenemos tanta información como podamos. 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 toma y almacena una referencia a nuestro código HTML principal. raíz del elemento. La siguiente línea calcula dónde se encuentra el medio de nuestro elemento, así que puede decidir si un toque es hacia adelante o hacia atrás.

Estado

A continuación, haremos un objeto pequeño con un estado relevante para nuestra lógica. En este caso, solo nos interesa la historia actual. En nuestro lenguaje de marcado HTML, podemos accede a él captando al 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 eventos de usuarios 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 está en un elemento <article>, lo protegemos y no hacemos nada. Si se trata de un artículo, tomamos la posición horizontal del mouse o del dedo con clientX Aún no implementamos navigateStories, pero el argumento que indica que que toma, especifica la dirección a la que debemos ir. Si esa posición del usuario es mayor que la mediana, sabemos que debemos navegar a next; de lo contrario, prev (anterior).

Teclado

Ahora escuchemos cuando presiones el 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 por las historias

Es hora de abordar la lógica empresarial única de las historias y la UX en la que se convirtieron. famoso por. Esto se ve grueso y complicado, pero creo que si lo tomas encontrarás que es bastante asimilable.

Por adelantado, guardamos algunos selectores que nos ayudan a decidir si nos desplazamos a un amigo o mostrar/ocultar una historia. Como estamos trabajando con HTML, lo usaremos buscar en él la presencia de amigos (usuarios) o historias (historia).

Estas variables nos ayudarán a responder preguntas como: "Dada la historia x, ¿"siguiente" ¿Qué significa pasar a otra historia de este mismo amigo o de uno diferente?”. Lo hice usando el árbol que construimos, llegando a 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 parecido posible al lenguaje natural:

  • Decide cómo controlar el toque
    • Si hay una historia siguiente o anterior: mostrar esa historia
    • Si es la última o la primera historia de un amigo: enséñale a un amigo nuevo.
    • Si no hay ninguna historia a la cual dirigirse 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

Eso fue una conclusión para las necesidades que tenía con el componente. Siéntete libre de desarrollar impulsarlo con datos y, en general, personalizarlo.