Codelab: Membuat komponen Stories

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

Lihat postingan blog saya Membangun komponen Stories untuk mempelajari progressive enhancement yang dilakukan saat membangun komponen ini.

Penyiapan

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

HTML

Saya selalu berupaya menggunakan HTML semantik. Karena setiap teman dapat memiliki sejumlah cerita, saya rasa akan lebih bermakna 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 cerita.

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

CSS

Konten kita sudah siap untuk diberi gaya. Mari kita ubah kerangka tersebut menjadi sesuatu yang menarik untuk berinteraksi. Kita akan mengerjakan desain yang memprioritaskan perangkat seluler hari ini.

.stories

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

  • Menjadikan penampung sebagai Petak
  • Menyetel setiap turunan untuk mengisi jalur baris
  • Membuat lebar setiap turunan sama dengan lebar area pandang perangkat seluler

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

Chrome dan DevTools terbuka dengan visual petak yang menampilkan tata letak lebar penuh
Chrome DevTools menampilkan overflow 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 melampaui area pandang, saatnya memberi tahu penampung tersebut cara menanganinya. Tambahkan baris kode yang ditandai ke ruleset .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 ingin scroll horizontal, jadi kita akan menetapkan overflow-x ke auto. Saat pengguna men-scroll, kita ingin komponen berhenti perlahan 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 pengepasan posisi scroll, jadi mari kita tangani hal itu 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 beralih ke artikel berikutnya. Jika dinonaktifkan, browser akan menggunakan perilaku scroll defaultnya.

Dengan begitu, Anda dapat men-scroll daftar teman, tetapi kita masih memiliki masalah dengan story yang harus diselesaikan.

.user

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

Tambahkan kode yang ditandai ke ruleset .user Anda:

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

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

.story {
  grid-area: story;
}

Sekarang, tanpa pemosisian absolut, float, atau perintah tata letak lainnya yang mengeluarkan elemen dari alur, kita masih berada dalam alur. Selain itu, hampir tidak ada 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, 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 mengurutkannya sehingga gambar pengguna berada di atas dan akan muncul secara otomatis setelah selesai dimuat. Untuk mengaktifkannya, kita akan menempatkan URL gambar ke dalam properti kustom (--bg), dan menggunakannya dalam CSS untuk melapisi dengan placeholder pemuatan.

Pertama, mari kita perbarui ruleset .story untuk mengganti gradien dengan gambar latar setelah selesai dimuat. Tambahkan kode yang ditandai ke ruleset .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 menggunakan 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 didownload.

Selanjutnya, kita akan menambahkan beberapa CSS untuk menghapus beberapa perilaku, sehingga browser dapat bergerak lebih cepat. Tambahkan kode yang ditandai ke ruleset .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 menginstruksikan browser bahwa interaksi ini harus diperlakukan sebagai peristiwa sentuh, yang membebaskan browser dari upaya memutuskan apakah Anda mengklik URL atau tidak

Terakhir, mari kita tambahkan sedikit CSS untuk menganimasikan transisi antar-story. Tambahkan kode yang ditandai ke ruleset .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 story yang memerlukan exit. Saya mendapatkan fungsi easing kustom (cubic-bezier(0.4, 0.0, 1,1)) dari panduan Easing Desain Material (scroll ke bagian Accelerated easing).

Jika Anda jeli, Anda mungkin melihat deklarasi pointer-events: none dan menggaruk-garuk kepala sekarang. Menurut saya, ini adalah satu-satunya kelemahan solusi ini sejauh ini. Kita memerlukan ini karena elemen .seen.story akan berada di atas dan akan menerima ketukan, meskipun tidak terlihat. Dengan menyetel pointer-events ke none, kita mengubah story kaca menjadi jendela, dan tidak lagi mencuri interaksi pengguna. Kompromi yang tidak terlalu buruk, tidak terlalu sulit untuk dikelola di sini, dalam CSS kita saat ini. Kami tidak melakukan juggling z-index. Saya masih merasa yakin dengan hal ini.

JavaScript

Interaksi komponen Stories cukup sederhana bagi pengguna: ketuk di sebelah kanan untuk maju, 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 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 mengambil dan menyimpan referensi ke root elemen HTML utama. Baris berikutnya menghitung posisi tengah elemen, sehingga kita dapat memutuskan apakah ketukan akan mengarah ke depan atau ke belakang.

Negara Bagian

Selanjutnya, kita membuat objek kecil dengan beberapa status yang relevan dengan logika kita. Dalam hal ini, kita hanya tertarik pada 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 Stories. 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 berupa artikel, kita akan mengambil posisi horizontal mouse atau jari dengan clientX. Kita belum menerapkan navigateStories, tetapi argumen yang diperlukan menentukan arah yang harus kita tuju. Jika posisi pengguna tersebut lebih besar dari median, kita tahu bahwa kita perlu membuka next, jika tidak, prev (sebelumnya).

Keyboard

Sekarang, mari kita proses penekanan 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 menangani logika bisnis unik cerita dan UX yang membuatnya terkenal. Ini terlihat rumit dan sulit, tetapi jika Anda membacanya baris demi baris, Anda akan merasa bahwa 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 kita bekerja di HTML, kita akan membuat kueri untuk kehadiran teman (pengguna) atau cerita (cerita).

Variabel ini akan membantu kita menjawab pertanyaan seperti, "dengan cerita x, apakah "berikutnya" berarti beralih ke cerita lain dari teman yang sama atau ke teman yang berbeda?" Saya melakukannya dengan menggunakan struktur hierarki yang kami 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, yang sedekat mungkin dengan bahasa alami:

  • Menentukan cara menangani ketukan
    • Jika ada cerita berikutnya/sebelumnya: tampilkan cerita tersebut
    • Jika ini adalah cerita terakhir/pertama teman: tampilkan teman baru
    • Jika tidak ada cerita yang dapat dibuka ke arah tersebut: tidak melakukan apa pun
  • Simpan cerita 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 View App. Kemudian, tekan Fullscreen layar penuh.

Kesimpulan

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