Codelab: Membuat komponen Stories

Codelab ini mengajarkan cara membuat pengalaman seperti Instagram Stories di web. Kita akan mem-build komponen seiring berjalannya waktu, dimulai dengan HTML, lalu CSS, lalu JavaScript.

Lihat postingan blog saya Mem-build komponen Stories untuk mempelajari peningkatan progresif yang dilakukan saat mem-build komponen ini.

Penyiapan

  1. Klik Remix to Edit untuk membuat project dapat diedit.
  2. Buka app/index.html.

HTML

Saya selalu berusaha menggunakan HTML semantik. Karena setiap teman dapat memiliki cerita dalam jumlah berapa pun, saya pikir akan lebih baik jika menggunakan elemen <section> untuk setiap teman dan elemen <article> untuk setiap cerita. Namun, mari kita mulai dari awal. Pertama, kita memerlukan penampung untuk komponen story.

Tambahkan elemen <div> ke <body>:

<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 mewakili cerita:

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

CSS

Konten kami siap untuk diberi gaya. Mari kita ubah tulang-tulang tersebut menjadi sesuatu yang ingin digunakan orang untuk berinteraksi. Hari ini kita akan mengerjakan versi seluler.

.stories

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

  • Membuat penampung menjadi Petak
  • Menetapkan setiap turunan untuk mengisi jalur baris
  • Membuat lebar setiap turunan menjadi lebar area pandang perangkat seluler

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

Chrome dan DevTools terbuka dengan visual petak yang menampilkan tata letak lebar penuh
Chrome DevTools menampilkan kolom petak yang terlampaui, sehingga membuat penggeser 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 di luar area pandang, sekarang saatnya memberi tahu penampung 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 beristirahat dengan lembut di story berikutnya, jadi kita akan menggunakan scroll-snap-type: x mandatory. Baca selengkapnya tentang CSS ini di bagian CSS Scroll Snap Points dan overscroll-behavior di postingan blog saya.

Penampung induk dan turunan harus 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 langsung beralih ke artikel berikutnya. Jika dinonaktifkan, browser akan menggunakan perilaku scroll default-nya.

Tindakan ini akan membuat Anda men-scroll teman, tetapi kami masih memiliki masalah dengan artikel yang harus diselesaikan.

.user

Mari kita buat tata letak di bagian .user yang mengatur elemen cerita turunan tersebut. Kita akan menggunakan trik penumpukan yang praktis untuk mengatasinya. Pada dasarnya, kita membuat petak 1x1 dengan baris dan kolom memiliki alias Grid yang sama dengan [story], dan setiap item petak cerita akan mencoba dan mengklaim ruang tersebut, sehingga menghasilkan stack.

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 lainnya yang membuat elemen keluar dari alur, kita masih dalam alur. Selain itu, hampir tidak ada kode, lihat itu! Hal ini dijelaskan secara lebih mendetail dalam video dan postingan blog.

.story

Sekarang kita hanya perlu menata gaya item cerita itu sendiri.

Sebelumnya, kami menyebutkan bahwa atribut style pada 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 menyusunnya sehingga gambar pengguna kita berada di atas dan akan muncul secara otomatis setelah selesai dimuat. Untuk mengaktifkannya, kita akan memasukkan URL gambar ke dalam properti kustom (--bg), dan menggunakannya dalam CSS untuk ditumpuk dengan placeholder pemuatan.

Pertama, mari perbarui kumpulan aturan .story untuk mengganti gradien dengan gambar latar belakang setelah selesai dimuat. 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 memastikan tidak ada ruang kosong di area pandang karena gambar kita akan mengisinya. Dengan menentukan 2 gambar latar, kita dapat menggunakan trik web CSS yang rapi yang disebut loading tombstone:

  • Gambar latar 1 (var(--bg)) adalah URL yang kami 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 didownload.

Selanjutnya, kita akan menambahkan beberapa CSS untuk menghapus beberapa perilaku, sehingga browser dapat 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 upaya untuk memutuskan apakah Anda mengklik URL atau tidak

Terakhir, mari kita tambahkan sedikit CSS untuk menganimasikan transisi antar-cerita. 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 keluar. Saya mendapatkan fungsi easing kustom (cubic-bezier(0.4, 0.0, 1,1)) dari panduan Easing Desain Material (scroll ke bagian Easing yang dipercepat).

Jika Anda memiliki mata yang tajam, Anda mungkin melihat deklarasi pointer-events: none dan sekarang sedang memutar otak. Saya rasa ini adalah satu-satunya kelemahan 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 story kaca menjadi jendela, dan tidak lagi mencuri interaksi pengguna. Tidak terlalu buruk, tidak terlalu sulit dikelola di sini di CSS kita saat ini. Kita tidak melakukan juggling z-index. Saya masih merasa baik dengan hal ini.

JavaScript

Interaksi komponen Stories cukup sederhana bagi pengguna: ketuk di sebelah kanan untuk melanjutkan, ketuk di sebelah kiri untuk kembali. Hal-hal sederhana bagi pengguna cenderung menjadi pekerjaan berat bagi developer. Namun, kami akan menangani banyak hal.

Penyiapan

Untuk memulai, mari kita komputasi 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 mengambil dan menyimpan referensi ke root elemen HTML utama. Baris berikutnya menghitung letak 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 kita. Dalam hal ini, kita hanya tertarik dengan cerita saat ini. Dalam markup HTML, kita dapat mengaksesnya dengan mengambil teman pertama dan story terbarunya. 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
}

Pemroses

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

Tikus

Mari kita mulai dengan memproses 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 di elemen <article>, kita akan keluar dan tidak melakukan apa pun. Jika itu adalah artikel, kita mengambil posisi horizontal mouse atau jari dengan clientX. Kita belum menerapkan navigateStories, tetapi argumen yang dibutuhkan menentukan arah yang harus kita tuju. Jika posisi pengguna tersebut lebih besar dari median, kita tahu bahwa kita harus membuka next, jika tidak, prev (sebelumnya).

Keyboard

Sekarang, mari kita dengarkan penekanan tombol keyboard. Jika Panah Bawah ditekan, kita akan membuka next. Jika tombolnya adalah Panah Atas, kita akan membuka 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 Stories

Saatnya membahas logika bisnis unik dari story dan UX yang membuat mereka terkenal. Ini terlihat tebal dan rumit, tetapi saya rasa jika Anda membacanya baris demi baris, Anda akan menemukan bahwa kode ini cukup mudah dipahami.

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

Variabel ini akan membantu kita menjawab pertanyaan seperti, "di cerita x, apakah "berikutnya" berarti beralih ke cerita lain dari teman yang sama ini atau ke teman yang berbeda?" Saya melakukannya dengan menggunakan struktur pohon yang kita buat, yang menjangkau induk dan turunannya.

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 kita, sedekat mungkin dengan bahasa alami:

  • Tentukan cara menangani ketukan
    • Jika ada cerita berikutnya/sebelumnya: tampilkan cerita tersebut
    • Jika ini adalah story terakhir/pertama dari teman: tampilkan teman baru
    • Jika tidak ada cerita yang dapat dituju ke arah tersebut: jangan lakukan apa pun
  • Simpan artikel saat ini yang baru 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. Kemudian tekan Layar Penuh layar penuh.

Kesimpulan

Itulah rangkuman kebutuhan saya dengan komponen. Jangan ragu untuk mengembangkannya, mendorongnya dengan data, dan secara umum menjadikannya milik Anda.