Codelab: Membuat komponen Stories

Codelab ini mengajarkan cara membangun pengalaman seperti Instagram Stories di web. Kita akan membuat komponen sambil berjalan, mulai dengan HTML, lalu CSS, lalu JavaScript.

Lihat postingan blog saya yang berjudul Membuat komponen Stories untuk mempelajari peningkatan progresif yang dilakukan saat membuat komponen ini.

Penyiapan

  1. Klik Remix untuk Mengedit agar project dapat diedit.
  2. Buka app/index.html.

HTML

Saya selalu berupaya menggunakan HTML semantik. Karena setiap teman dapat memiliki berapa pun cerita, menurut saya akan bermanfaat jika menggunakan elemen <section> untuk setiap teman dan elemen <article> untuk setiap cerita. Mari kita mulai dari awal. Pertama, kita membutuhkan kontainer untuk komponen {i>story<i}.

Tambahkan elemen <div> ke <body> Anda:

<div class="stories">

</div>

Tambahkan beberapa elemen <section> untuk mewakili teman:

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

Tambahkan beberapa elemen <article> untuk merepresentasikan artikel:

<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>
  • Kami menggunakan layanan gambar (picsum.com) untuk membantu membuat prototipe cerita.
  • Atribut style pada setiap <article> adalah bagian dari teknik pemuatan placeholder, yang akan Anda pelajari lebih lanjut di bagian berikutnya.

CSS

Konten kita siap untuk bergaya. Mari kita ubah tulang-tulang itu menjadi sesuatu yang ingin berinteraksi dengan orang-orang. Hari ini kami akan bekerja dengan {i>mobile-first<i}.

.stories

Untuk penampung <div class="stories">, kita menginginkan penampung scroll horizontal. Kita dapat mencapainya dengan:

  • Membuat container menjadi Grid
  • Menetapkan setiap turunan untuk mengisi trek baris
  • Membuat lebar setiap anak selebar area pandang perangkat seluler

Petak akan terus menempatkan kolom baru selebar 100vw di sebelah kanan kolom sebelumnya, hingga semua elemen HTML di markup Anda ditempatkan.

Chrome dan DevTools terbuka dengan visual petak yang menunjukkan tata letak lebar penuh
Chrome DevTools yang menampilkan tambahan kolom petak, sehingga membuat scroller horizontal.

Tambahkan CSS berikut ke bagian bawah app/css/index.css:

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

Setelah kita memiliki konten yang meluas ke luar area tampilan, saatnya memberi tahu container tersebut cara menanganinya. Tambahkan baris kode yang ditandai ke kumpulan aturan .stories Anda:

.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;
}

Kita menginginkan scroll horizontal, jadi kita akan menetapkan overflow-x ke auto. Saat pengguna men-scroll, kita ingin komponen diletakkan perlahan di cerita berikutnya, jadi kita akan menggunakan scroll-snap-type: x mandatory. Baca selengkapnya tentang CSS ini di bagian Titik Snap Scroll CSS dan perilaku overscroll di postingan blog saya.

Perlu penampung induk dan turunan untuk menyetujui snap scroll, jadi mari kita tangani sekarang. Tambahkan kode berikut ke bagian bawah app/css/index.css:

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

Aplikasi Anda belum berfungsi, tetapi video di bawah menunjukkan apa yang terjadi saat scroll-snap-type diaktifkan dan dinonaktifkan. Jika diaktifkan, setiap scroll horizontal akan mengarah ke cerita berikutnya. Jika dinonaktifkan, browser akan menggunakan perilaku scroll default.

Ini akan membuat Anda menelusuri teman-teman Anda, tetapi kami masih memiliki masalah dengan cerita yang harus dipecahkan.

.user

Mari kita buat tata letak di bagian .user yang menyusun elemen cerita turunan tersebut pada tempatnya. Kami akan menggunakan trik tumpukan yang praktis untuk menyelesaikan ini. Pada dasarnya, kita membuat petak 1x1 dengan baris dan kolom yang memiliki alias Petak yang sama, yaitu [story], dan setiap item petak cerita akan mencoba mengklaim ruang tersebut, sehingga menghasilkan tumpukan.

Tambahkan kode yang ditandai ke kumpulan aturan .user Anda:

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

Tambahkan kumpulan aturan berikut ke bagian bawah app/css/index.css:

.story {
  grid-area: story;
}

Sekarang, tanpa pemosisian absolut, float, atau perintah tata letak lain yang mengeluarkan elemen dari alur, kita masih dalam alur. Plus, ini seperti hampir semua kode, lihat itu. Hal ini diuraikan dalam video dan postingan blog secara lebih mendetail.

.story

Sekarang kita hanya perlu menata gaya item cerita itu sendiri.

Sebelumnya, kita menyebutkan bahwa atribut style di setiap elemen <article> adalah bagian dari teknik pemuatan placeholder:

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

Kita akan menggunakan properti background-image CSS, yang memungkinkan kita menentukan lebih dari satu gambar latar. Kita dapat mengurutkannya sehingga gambar pengguna berada di atas dan akan muncul secara otomatis setelah pemuatan selesai. Untuk mengaktifkannya, kita akan menempatkan URL gambar ke properti khusus (--bg), dan menggunakannya dalam CSS untuk memberi lapisan dengan placeholder pemuatan.

Pertama, mari kita perbarui kumpulan aturan .story untuk mengganti gradien dengan gambar latar setelah pemuatan selesai. Tambahkan kode yang ditandai ke kumpulan aturan .story Anda:

.story {
  grid-area: story;

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

Menetapkan background-size ke cover akan memastikan tidak ada ruang kosong di area pandang karena gambar kita akan mengisinya. Dengan menentukan 2 gambar latar, kita dapat menarik trik web CSS yang rapi yang disebut loading tombstone:

  • Gambar latar 1 (var(--bg)) adalah URL yang kita teruskan secara inline di HTML
  • Gambar latar 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) adalah gradien yang akan ditampilkan saat URL dimuat

CSS akan otomatis mengganti gradien dengan gambar, setelah gambar selesai diunduh.

Selanjutnya kita akan menambahkan beberapa CSS untuk menghapus beberapa perilaku, sehingga browser bisa bergerak lebih cepat. Tambahkan kode yang ditandai ke kumpulan aturan .story Anda:

.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 mencegah pengguna memilih teks secara tidak sengaja
  • touch-action: manipulation memberi tahu browser bahwa interaksi ini harus diperlakukan sebagai peristiwa sentuh, yang membebaskan browser dari mencoba memutuskan apakah Anda mengklik URL atau tidak

Terakhir, mari tambahkan sedikit CSS untuk menganimasikan transisi antarcerita. Tambahkan kode yang ditandai ke kumpulan aturan .story Anda:

.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;
  }
}

Class .seen akan ditambahkan ke cerita yang memerlukan jalan keluar. Saya mendapatkan fungsi easing kustom (cubic-bezier(0.4, 0.0, 1,1)) dari panduan Easing Desain Material (scroll ke bagian Accerlerated easing).

Jika Anda memiliki mata yang tajam, Anda mungkin memperhatikan deklarasi pointer-events: none dan sedang menggaruk-garuk kepala. Saya akan mengatakan bahwa ini adalah satu-satunya kelemahan dari solusi sejauh ini. Kita memerlukannya karena elemen .seen.story akan berada di atas dan akan menerima ketukan, meskipun tidak terlihat. Dengan menetapkan pointer-events ke none, kita mengubah cerita kaca menjadi jendela, dan tidak mencuri lagi interaksi pengguna. Tidak terlalu buruk, tidak terlalu sulit untuk dikelola di CSS kami saat ini. Kami tidak melakukan juggling pada z-index. Saya masih merasa nyaman dengan ini.

JavaScript

Interaksi komponen Stories cukup sederhana bagi pengguna: ketuk kanan untuk melanjutkan, ketuk di sebelah kiri untuk kembali. Hal-hal sederhana bagi pengguna cenderung menjadi kerja keras bagi pengembang. Tapi kami akan menangani banyak hal ini.

Penyiapan

Untuk memulainya, mari kita hitung dan simpan informasi sebanyak mungkin. Tambahkan kode berikut ke app/js/index.js:

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

Baris pertama JavaScript kita mengambil dan menyimpan referensi ke root elemen HTML utama. Baris berikutnya menghitung di mana bagian tengah elemen, sehingga kita dapat memutuskan apakah ketukan akan maju atau mundur.

Negara Bagian

Selanjutnya, kita membuat objek kecil dengan beberapa status yang relevan dengan logika. Dalam hal ini, kita hanya tertarik pada cerita saat ini. Dalam markup HTML, kita dapat mengaksesnya dengan mengambil teman pertama dan kisah terbaru mereka. Tambahkan kode yang ditandai ke app/js/index.js Anda:

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

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

Pemroses

Kita memiliki logika yang cukup untuk mulai memproses peristiwa pengguna dan mengarahkannya.

Tikus

Mari kita mulai dengan mendengarkan peristiwa 'click' di penampung story. Tambahkan kode yang ditandai ke 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')
})

Jika klik terjadi dan tidak berada pada elemen <article>, kami menjamin dan tidak akan melakukan apa pun. Jika berupa artikel, kita ambil posisi horizontal mouse atau jari dengan clientX. Kita belum mengimplementasikan navigateStories, tetapi argumen yang diperlukan menentukan arah yang harus kita tempuh. Jika posisi pengguna tersebut lebih besar dari median, kita tahu kita harus menuju ke next, jika tidak, prev (sebelumnya).

Keyboard

Sekarang, mari kita dengarkan penekanan {i>keyboard<i}. Jika Panah Bawah ditekan, kita akan membuka next. Jika itu Panah Atas, kita mengarah ke prev.

Tambahkan kode yang ditandai ke 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')
})

Navigasi cerita

Saatnya menangani logika bisnis unik dari cerita dan UX yang membuatnya terkenal. Ini tampak rumit dan rumit, tetapi menurut saya, jika Anda mengambilnya baris demi baris, Anda akan merasa cukup mudah dicerna.

Di awal, kita menyimpan beberapa pemilih yang membantu memutuskan apakah akan men-scroll ke teman atau menampilkan/menyembunyikan cerita. Karena HTML adalah tempat kita bekerja, kita akan mengkuerinya untuk kehadiran teman (pengguna) atau cerita (cerita).

Variabel-variabel ini akan membantu kita menjawab pertanyaan seperti, "mengingat cerita x, apakah "berikutnya" berarti pindah ke cerita lain dari teman yang sama atau teman yang berbeda?" Saya melakukannya dengan menggunakan struktur pohon yang kami bangun, menjangkau orang tua dan anak mereka.

Tambahkan kode berikut ke bagian bawah 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
}

Berikut adalah sasaran logika bisnis kami, semirip mungkin dengan natural language:

  • Tentukan cara menangani ketukan
    • Jika ada artikel berikutnya/sebelumnya: tampilkan cerita tersebut
    • Jika itu cerita terakhir/pertama dari teman: perlihatkan teman baru
    • Jika tidak ada kisah untuk diarahkan ke sana: jangan lakukan apa pun
  • Simpan cerita baru saat ini ke state

Tambahkan kode yang ditandai ke fungsi navigateStories Anda:

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
    }
  }
}

Cobalah

  • Untuk melihat pratinjau situs, tekan Lihat Aplikasi. Lalu tekan Layar Penuh layar penuh.

Kesimpulan

Itulah rangkuman dari kebutuhan yang saya miliki dengan komponen. Jangan ragu untuk membangunnya, menggerakkannya dengan data, dan secara umum membuatnya menjadi milik Anda.