Mem-build komponen dialog

Ringkasan dasar tentang cara membuat modal mini dan mega yang adaptif terhadap warna, responsif, dan mudah diakses dengan elemen <dialog>.

Dalam postingan ini, saya ingin membagikan pemikiran saya tentang cara membuat modal mini dan mega yang adaptif terhadap warna, responsif, dan mudah diakses dengan elemen <dialog>. Coba demonya dan lihat sumbernya.

Demonstrasi dialog mega dan mini dalam tema terang dan gelap.

Jika Anda lebih suka menonton video, berikut versi YouTube dari postingan ini:

Ringkasan

Elemen <dialog> sangat cocok untuk informasi atau tindakan kontekstual dalam halaman. Pertimbangkan kapan pengalaman pengguna dapat memperoleh manfaat dari tindakan di halaman yang sama, bukan tindakan multi-halaman: mungkin karena formulirnya kecil atau satu-satunya tindakan yang diperlukan dari pengguna adalah mengonfirmasi atau membatalkan.

Elemen <dialog> baru-baru ini menjadi stabil di berbagai browser:

Browser Support

  • Chrome: 37.
  • Edge: 79.
  • Firefox: 98.
  • Safari: 15.4.

Source

Saya menemukan bahwa elemen tersebut tidak memiliki beberapa hal, jadi dalam Tantangan GUI ini, saya menambahkan item pengalaman developer yang saya harapkan: peristiwa tambahan, penutupan ringan, animasi kustom, dan jenis huruf mini dan mega.

Markup

Dasar-dasar elemen <dialog> cukup sederhana. Elemen akan disembunyikan secara otomatis dan memiliki gaya bawaan untuk menempatkan konten Anda.

<dialog>
  …
</dialog>

Kita dapat meningkatkan kualitas dasar ini.

Secara tradisional, elemen dialog memiliki banyak kesamaan dengan modal, dan sering kali nama keduanya dapat dipertukarkan. Di sini, saya menggunakan elemen dialog untuk pop-up dialog kecil (mini), serta dialog halaman penuh (mega). Saya menamainya mega dan mini, dengan kedua dialog sedikit disesuaikan untuk kasus penggunaan yang berbeda. Saya menambahkan atribut modal-mode agar Anda dapat menentukan jenisnya:

<dialog id="MegaDialog" modal-mode="mega"></dialog>
<dialog id="MiniDialog" modal-mode="mini"></dialog>

Screenshot dialog mini dan mega dalam tema terang dan gelap.

Tidak selalu, tetapi elemen dialog umumnya akan digunakan untuk mengumpulkan beberapa informasi interaksi. Formulir di dalam elemen dialog dibuat agar bersama-sama. Sebaiknya buat elemen formulir yang membungkus konten dialog Anda agar JavaScript dapat mengakses data yang telah dimasukkan pengguna. Selain itu, tombol di dalam formulir yang menggunakan method="dialog" dapat menutup dialog tanpa JavaScript dan meneruskan data.

<dialog id="MegaDialog" modal-mode="mega">
  <form method="dialog">
    …
    <button value="cancel">Cancel</button>
    <button value="confirm">Confirm</button>
  </form>
</dialog>

Dialog besar

Dialog mega memiliki tiga elemen di dalam formulir: <header>, <article>, dan <footer>. Elemen ini berfungsi sebagai penampung semantik, serta target gaya untuk presentasi dialog. Header memberi judul modal dan menawarkan tombol tutup. Artikel ini ditujukan untuk input dan informasi formulir. Footer berisi <menu> tombol tindakan.

<dialog id="MegaDialog" modal-mode="mega">
  <form method="dialog">
    <header>
      <h3>Dialog title</h3>
      <button onclick="this.closest('dialog').close('close')"></button>
    </header>
    <article>...</article>
    <footer>
      <menu>
        <button autofocus type="reset" onclick="this.closest('dialog').close('cancel')">Cancel</button>
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

Tombol menu pertama memiliki autofocus dan pengendali peristiwa inline onclick. Atribut autofocus akan menerima fokus saat dialog dibuka, dan menurut saya praktik terbaiknya adalah menempatkan ini di tombol batalkan, bukan tombol konfirmasi. Hal ini memastikan bahwa konfirmasi dilakukan dengan sengaja dan bukan secara tidak sengaja.

Dialog mini

Dialog mini sangat mirip dengan dialog mega, hanya saja tidak memiliki elemen <header>. Hal ini memungkinkan ikon menjadi lebih kecil dan lebih sejajar.

<dialog id="MiniDialog" modal-mode="mini">
  <form method="dialog">
    <article>
      <p>Are you sure you want to remove this user?</p>
    </article>
    <footer>
      <menu>
        <button autofocus type="reset" onclick="this.closest('dialog').close('cancel')">Cancel</button>
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

Elemen dialog memberikan fondasi yang kuat untuk elemen viewport penuh yang dapat mengumpulkan data dan interaksi pengguna. Dasar-dasar ini dapat menghasilkan beberapa interaksi yang sangat menarik dan efektif di situs atau aplikasi Anda.

Aksesibilitas

Elemen dialog memiliki aksesibilitas bawaan yang sangat baik. Alih-alih menambahkan fitur ini seperti yang biasa saya lakukan, banyak fitur sudah tersedia.

Memulihkan fokus

Seperti yang kita lakukan secara manual di Membangun komponen sidenav, penting agar membuka dan menutup sesuatu dengan benar memfokuskan pada tombol buka dan tutup yang relevan. Saat sidenav terbuka, fokus akan berada di tombol tutup. Saat tombol tutup ditekan, fokus akan dipulihkan ke tombol yang membukanya.

Dengan elemen dialog, ini adalah perilaku default bawaan:

Sayangnya, jika Anda ingin menganimasikan dialog masuk dan keluar, fungsi ini akan hilang. Di bagian JavaScript, saya akan memulihkan fungsi tersebut.

Memerangkap fokus

Elemen dialog mengelola inert untuk Anda di dokumen. Sebelum inert, JavaScript digunakan untuk memantau fokus yang keluar dari elemen, yang kemudian mencegat dan menempatkannya kembali.

Browser Support

  • Chrome: 102.
  • Edge: 102.
  • Firefox: 112.
  • Safari: 15.5.

Source

Setelah inert, bagian dokumen apa pun dapat "dibekukan" sehingga tidak lagi menjadi target fokus atau interaktif dengan mouse. Daripada menjebak fokus, fokus diarahkan ke satu-satunya bagian interaktif dalam dokumen.

Membuka dan memfokuskan elemen secara otomatis

Secara default, elemen dialog akan menetapkan fokus ke elemen pertama yang dapat difokuskan dalam markup dialog. Jika ini bukan elemen terbaik yang akan digunakan pengguna secara default, gunakan atribut autofocus. Seperti yang dijelaskan sebelumnya, menurut saya praktik terbaiknya adalah menempatkan ini di tombol batal, bukan tombol konfirmasi. Hal ini memastikan bahwa konfirmasi dilakukan dengan sengaja dan bukan secara tidak sengaja.

Menutup dengan tombol escape

Penting untuk mempermudah penutupan elemen yang berpotensi mengganggu ini. Untungnya, elemen dialog akan menangani tombol escape untuk Anda, sehingga Anda tidak perlu repot-repot melakukan orkestrasi.

Gaya

Ada jalur mudah untuk menata gaya elemen dialog dan jalur sulit. Jalur mudah dicapai dengan tidak mengubah properti tampilan dialog dan bekerja dengan batasannya. Saya memilih jalur sulit untuk menyediakan animasi kustom saat membuka dan menutup dialog, mengambil alih properti display, dan lainnya.

Menata gaya dengan Open Props

Untuk mempercepat warna adaptif dan konsistensi desain secara keseluruhan, saya dengan berani menggunakan library variabel CSS Open Props. Selain variabel gratis yang disediakan, saya juga mengimpor file normalize dan beberapa tombol, yang keduanya disediakan Open Props sebagai impor opsional. Impor ini membantu saya berfokus pada penyesuaian dialog dan demo tanpa memerlukan banyak gaya untuk mendukungnya dan membuatnya terlihat bagus.

Menata gaya elemen <dialog>

Memiliki properti tampilan

Perilaku tampil dan sembunyi default elemen dialog mengalihkan properti tampilan dari block ke none. Sayangnya, ini berarti elemen tidak dapat dianimasikan saat masuk dan keluar, hanya saat masuk. Saya ingin menganimasikan masuk dan keluar, dan langkah pertama adalah menetapkan properti display saya sendiri:

dialog {
  display: grid;
}

Dengan mengubah, dan oleh karena itu memiliki, nilai properti tampilan, seperti yang ditunjukkan dalam cuplikan CSS di atas, sejumlah besar gaya perlu dikelola untuk memfasilitasi pengalaman pengguna yang tepat. Pertama, status default dialog adalah tertutup. Anda dapat merepresentasikan status ini secara visual dan mencegah dialog menerima interaksi dengan gaya berikut:

dialog:not([open]) {
  pointer-events: none;
  opacity: 0;
}

Sekarang dialog tidak terlihat dan tidak dapat berinteraksi saat tidak terbuka. Nanti Saya akan menambahkan beberapa JavaScript untuk mengelola atribut inert pada dialog, sehingga pengguna keyboard dan pembaca layar juga tidak dapat menjangkau dialog tersembunyi.

Memberi dialog tema warna adaptif

Dialog mega yang menampilkan tema terang dan gelap, yang menunjukkan warna permukaan.

Meskipun color-scheme mengikutsertakan dokumen Anda dalam tema warna adaptif yang disediakan browser untuk preferensi sistem terang dan gelap, saya ingin menyesuaikan elemen dialog lebih dari itu. Open Props menyediakan beberapa warna permukaan yang beradaptasi secara otomatis dengan preferensi sistem terang dan gelap, mirip dengan penggunaan color-scheme. Warna ini sangat cocok untuk membuat lapisan dalam desain dan saya suka menggunakan warna untuk membantu mendukung tampilan permukaan lapisan ini secara visual. Warna latar belakangnya adalah var(--surface-1); untuk berada di atas lapisan tersebut, gunakan var(--surface-2):

dialog {
  
  background: var(--surface-2);
  color: var(--text-1);
}

@media (prefers-color-scheme: dark) {
  dialog {
    border-block-start: var(--border-size-1) solid var(--surface-3);
  }
}

Warna yang lebih adaptif akan ditambahkan nanti untuk elemen turunan, seperti header dan footer. Saya menganggapnya sebagai tambahan untuk elemen dialog, tetapi sangat penting dalam membuat desain dialog yang menarik dan didesain dengan baik.

Penentuan ukuran dialog responsif

Secara default, dialog mendelegasikan ukurannya ke kontennya, yang umumnya bagus. Tujuan saya di sini adalah membatasi max-inline-size ke ukuran yang dapat dibaca (--size-content-3 = 60ch) atau 90% dari lebar area pandang. Hal ini memastikan dialog tidak ditampilkan di layar penuh pada perangkat seluler, dan tidak terlalu lebar di layar desktop sehingga sulit dibaca. Kemudian, saya menambahkan max-block-size agar dialog tidak melebihi tinggi halaman. Artinya, kita juga perlu menentukan lokasi area scroll dialog, jika elemen dialognya tinggi.

dialog {
  
  max-inline-size: min(90vw, var(--size-content-3));
  max-block-size: min(80vh, 100%);
  max-block-size: min(80dvb, 100%);
  overflow: hidden;
}

Perhatikan bagaimana saya memiliki max-block-size dua kali? Yang pertama menggunakan 80vh, unit area tampilan fisik. Yang saya inginkan adalah menjaga dialog dalam alur relatif, untuk pengguna internasional, jadi saya menggunakan unit dvb yang logis, lebih baru, dan hanya didukung sebagian dalam deklarasi kedua saat unit tersebut menjadi lebih stabil.

Pemosisian dialog mega

Untuk membantu memosisikan elemen dialog, ada baiknya memecah dua bagiannya: latar belakang layar penuh dan penampung dialog. Latar belakang harus menutupi semuanya, memberikan efek bayangan untuk membantu mendukung bahwa dialog ini berada di depan dan konten di belakangnya tidak dapat diakses. Penampung dialog bebas untuk memusatkan dirinya di atas latar belakang ini dan mengambil bentuk apa pun yang diperlukan kontennya.

Gaya berikut memperbaiki elemen dialog ke jendela, meregangkannya ke setiap sudut, dan menggunakan margin: auto untuk memusatkan konten:

dialog {
  
  margin: auto;
  padding: 0;
  position: fixed;
  inset: 0;
  z-index: var(--layer-important);
}
Gaya dialog mega seluler

Pada area tampilan kecil, saya menata modal mega halaman penuh ini sedikit berbeda. Saya menetapkan margin bawah ke 0, yang membawa konten dialog ke bagian bawah area pandang. Dengan beberapa penyesuaian gaya, saya dapat mengubah dialog menjadi actionsheet, yang lebih dekat dengan ibu jari pengguna:

@media (max-width: 768px) {
  dialog[modal-mode="mega"] {
    margin-block-end: 0;
    border-end-end-radius: 0;
    border-end-start-radius: 0;
  }
}

Screenshot devtools yang melapisi jarak margin 
  di dialog mega desktop dan seluler saat terbuka.

Penempatan dialog mini

Saat menggunakan area tampilan yang lebih besar seperti di komputer desktop, saya memilih untuk memosisikan dialog mini di atas elemen yang memanggilnya. Untuk melakukannya, saya memerlukan JavaScript. Anda dapat menemukan teknik yang saya gunakan di sini, tetapi saya merasa hal ini di luar cakupan artikel ini. Tanpa JavaScript, dialog mini akan muncul di tengah layar, seperti dialog mega.

Buat desain tampil menarik

Terakhir, tambahkan sentuhan unik pada dialog agar terlihat seperti permukaan lembut yang berada jauh di atas halaman. Kelembutan dicapai dengan membulatkan sudut dialog. Kedalaman dicapai dengan salah satu shadow props Open Props yang dibuat dengan cermat:

dialog {
  
  border-radius: var(--radius-3);
  box-shadow: var(--shadow-6);
}

Menyesuaikan elemen semu latar belakang

Saya memilih untuk bekerja dengan latar belakang secara ringan, hanya menambahkan efek blur dengan backdrop-filter ke dialog mega:

Browser Support

  • Chrome: 76.
  • Edge: 79.
  • Firefox: 103.
  • Safari: 18.

Source

dialog[modal-mode="mega"]::backdrop {
  backdrop-filter: blur(25px);
}

Saya juga memilih untuk menerapkan transisi pada backdrop-filter, dengan harapan browser akan mengizinkan transisi elemen latar belakang di masa mendatang:

dialog::backdrop {
  transition: backdrop-filter .5s ease;
}

Screenshot dialog mega yang menutupi latar belakang buram avatar warna-warni.

Ekstra gaya

Saya menyebut bagian ini "tambahan" karena lebih berkaitan dengan demo elemen dialog saya daripada elemen dialog secara umum.

Penampungan scroll

Saat dialog ditampilkan, pengguna masih dapat men-scroll halaman di belakangnya, yang tidak saya inginkan:

Biasanya, overscroll-behavior adalah solusi yang biasa saya gunakan, tetapi menurut spesifikasi, hal ini tidak berpengaruh pada dialog karena bukan port scroll, yaitu, bukan penggeser sehingga tidak ada yang perlu dicegah. Saya dapat menggunakan JavaScript untuk memantau peristiwa baru dari panduan ini, seperti "tertutup" dan "terbuka", serta mengalihkan overflow: hidden pada dokumen, atau saya dapat menunggu hingga :has() stabil di semua browser:

Browser Support

  • Chrome: 105.
  • Edge: 105.
  • Firefox: 121.
  • Safari: 15.4.

Source

html:has(dialog[open][modal-mode="mega"]) {
  overflow: hidden;
}

Sekarang, saat dialog mega terbuka, dokumen HTML memiliki overflow: hidden.

Tata letak <form>

Selain menjadi elemen yang sangat penting untuk mengumpulkan informasi interaksi dari pengguna, saya menggunakannya di sini untuk menata elemen header, footer, dan artikel. Dengan tata letak ini, saya bermaksud mengartikulasikan turunan artikel sebagai area yang dapat di-scroll. Saya melakukannya dengan grid-template-rows. Elemen artikel diberi 1fr dan formulir itu sendiri memiliki tinggi maksimum yang sama dengan elemen dialog. Menetapkan tinggi tetap dan ukuran baris tetap inilah yang memungkinkan elemen artikel dibatasi dan di-scroll saat meluap:

dialog > form {
  display: grid;
  grid-template-rows: auto 1fr auto;
  align-items: start;
  max-block-size: 80vh;
  max-block-size: 80dvb;
}

Screenshot devtools yang melapisi informasi tata letak petak di atas baris.

Menata gaya dialog <header>

Peran elemen ini adalah untuk memberikan judul pada konten dialog dan menawarkan tombol tutup yang mudah ditemukan. Elemen ini juga diberi warna permukaan agar tampak berada di belakang konten artikel dialog. Persyaratan ini menghasilkan penampung flexbox, item yang disusun secara vertikal yang diberi jarak ke tepinya, dan beberapa padding dan jarak untuk memberi ruang bagi judul dan tombol tutup:

dialog > form > header {
  display: flex;
  gap: var(--size-3);
  justify-content: space-between;
  align-items: flex-start;
  background: var(--surface-2);
  padding-block: var(--size-3);
  padding-inline: var(--size-5);
}

@media (prefers-color-scheme: dark) {
  dialog > form > header {
    background: var(--surface-1);
  }
}

Screenshot Chrome Devtools yang melapisi informasi tata letak flexbox di header dialog.

Menata gaya tombol tutup header

Karena demo menggunakan tombol Open Props, tombol tutup disesuaikan menjadi tombol berpusat pada ikon bulat seperti berikut:

dialog > form > header > button {
  border-radius: var(--radius-round);
  padding: .75ch;
  aspect-ratio: 1;
  flex-shrink: 0;
  place-items: center;
  stroke: currentColor;
  stroke-width: 3px;
}

Screenshot Chrome Devtools yang menampilkan informasi ukuran dan padding untuk tombol tutup header.

Menata gaya dialog <article>

Elemen artikel memiliki peran khusus dalam dialog ini: elemen ini adalah ruang yang dimaksudkan untuk di-scroll jika dialognya tinggi atau panjang.

Untuk melakukannya, elemen formulir induk telah menetapkan beberapa nilai maksimum untuk dirinya sendiri yang memberikan batasan bagi elemen artikel ini untuk dicapai jika tingginya terlalu besar. Tetapkan overflow-y: auto sehingga scrollbar hanya ditampilkan jika diperlukan, sertakan scrolling di dalamnya dengan overscroll-behavior: contain, dan sisanya akan menjadi gaya presentasi kustom:

dialog > form > article {
  overflow-y: auto; 
  max-block-size: 100%; /* safari */
  overscroll-behavior-y: contain;
  display: grid;
  justify-items: flex-start;
  gap: var(--size-3);
  box-shadow: var(--shadow-2);
  z-index: var(--layer-1);
  padding-inline: var(--size-5);
  padding-block: var(--size-3);
}

@media (prefers-color-scheme: light) {
  dialog > form > article {
    background: var(--surface-1);
  }
}

Peran footer adalah untuk memuat menu tombol tindakan. Flexbox digunakan untuk menyelaraskan konten ke akhir sumbu inline footer, lalu beberapa jarak untuk memberi ruang pada tombol.

dialog > form > footer {
  background: var(--surface-2);
  display: flex;
  flex-wrap: wrap;
  gap: var(--size-3);
  justify-content: space-between;
  align-items: flex-start;
  padding-inline: var(--size-5);
  padding-block: var(--size-3);
}

@media (prefers-color-scheme: dark) {
  dialog > form > footer {
    background: var(--surface-1);
  }
}

Screenshot Chrome Devtools yang menampilkan informasi tata letak flexbox pada elemen footer.

Elemen menu digunakan untuk memuat tombol tindakan untuk dialog. Tata letak flexbox yang dilipat dengan gap digunakan untuk menyediakan ruang di antara tombol. Elemen menu memiliki padding seperti <ul>. Saya juga menghapus gaya tersebut karena tidak diperlukan.

dialog > form > footer > menu {
  display: flex;
  flex-wrap: wrap;
  gap: var(--size-3);
  padding-inline-start: 0;
}

dialog > form > footer > menu:only-child {
  margin-inline-start: auto;
}

Screenshot Chrome Devtools yang menampilkan informasi flexbox pada elemen menu footer.

Animasi

Elemen dialog sering kali dianimasikan karena masuk dan keluar dari jendela. Memberikan beberapa gerakan pendukung untuk masuk dan keluar dialog ini membantu pengguna mengorientasikan diri dalam alur.

Biasanya, elemen dialog hanya dapat dianimasikan saat masuk, bukan saat keluar. Hal ini karena browser mengalihkan properti display pada elemen. Sebelumnya, panduan menetapkan tampilan ke petak, dan tidak pernah menetapkannya ke tidak ada. Hal ini memungkinkan Anda membuat animasi masuk dan keluar.

Open Props dilengkapi dengan banyak animasi keyframe untuk digunakan, yang membuat orkestrasi menjadi mudah dan jelas. Berikut adalah tujuan animasi dan pendekatan berlapis yang saya lakukan:

  1. Gerakan yang dikurangi adalah transisi default, yaitu memudar masuk dan keluar dengan opasitas sederhana.
  2. Jika gerakan tidak masalah, animasi geser dan skala akan ditambahkan.
  3. Tata letak seluler responsif untuk dialog besar disesuaikan agar dapat meluncur keluar.

Transisi default yang aman dan bermakna

Meskipun Open Props dilengkapi dengan keyframe untuk memudar dan keluar, saya lebih memilih pendekatan transisi berlapis ini sebagai default dengan animasi keyframe sebagai potensi upgrade. Sebelumnya, kita telah menata gaya visibilitas dialog dengan opasitas, mengatur 1 atau 0 bergantung pada atribut [open]. Untuk melakukan transisi antara 0% dan 100%, beri tahu browser durasi dan jenis percepatan yang Anda inginkan:

dialog {
  transition: opacity .5s var(--ease-3);
}

Menambahkan gerakan ke transisi

Jika pengguna tidak keberatan dengan gerakan, dialog mega dan mini harus meluncur ke atas saat muncul, dan mengecil saat keluar. Anda dapat melakukannya dengan kueri media prefers-reduced-motion dan beberapa Open Props:

@media (prefers-reduced-motion: no-preference) {
  dialog {
    animation: var(--animation-scale-down) forwards;
    animation-timing-function: var(--ease-squish-3);
  }

  dialog[open] {
    animation: var(--animation-slide-in-up) forwards;
  }
}

Menyesuaikan animasi keluar untuk perangkat seluler

Sebelumnya di bagian gaya, gaya dialog mega diadaptasi untuk perangkat seluler agar lebih seperti sheet tindakan, seolah-olah selembar kecil kertas telah meluncur ke atas dari bagian bawah layar dan masih terpasang di bagian bawah. Animasi keluar skala tidak sesuai dengan desain baru ini, dan kita dapat menyesuaikannya dengan beberapa kueri media dan beberapa Open Props:

@media (prefers-reduced-motion: no-preference) and @media (max-width: 768px) {
  dialog[modal-mode="mega"] {
    animation: var(--animation-slide-out-down) forwards;
    animation-timing-function: var(--ease-squish-2);
  }
}

JavaScript

Ada beberapa hal yang dapat ditambahkan dengan JavaScript:

// dialog.js
export default async function (dialog) {
  // add light dismiss
  // add closing and closed events
  // add opening and opened events
  // add removed event
  // removing loading attribute
}

Penambahan ini berasal dari keinginan untuk menutup ringan (mengklik latar belakang dialog), animasi, dan beberapa peristiwa tambahan untuk pengaturan waktu yang lebih baik dalam mendapatkan data formulir.

Menambahkan penutupan ringan

Tugas ini mudah dan merupakan tambahan yang bagus untuk elemen dialog yang tidak beranimasi. Interaksi dicapai dengan memantau klik pada elemen dialog dan memanfaatkan bubbling peristiwa untuk menilai apa yang diklik, dan hanya akan close() jika elemen tersebut adalah elemen paling atas:

export default async function (dialog) {
  dialog.addEventListener('click', lightDismiss)
}

const lightDismiss = ({target:dialog}) => {
  if (dialog.nodeName === 'DIALOG')
    dialog.close('dismiss')
}

Perhatikan dialog.close('dismiss'). Peristiwa dipanggil dan string diberikan. String ini dapat diambil oleh JavaScript lain untuk mendapatkan insight tentang cara penutupan dialog. Anda akan melihat bahwa saya juga memberikan string penutup setiap kali saya memanggil fungsi dari berbagai tombol, untuk memberikan konteks ke aplikasi saya tentang interaksi pengguna.

Menambahkan acara penutup dan tertutup

Elemen dialog dilengkapi dengan peristiwa penutupan: peristiwa ini segera dipancarkan saat fungsi close() dialog dipanggil. Karena kita menganimasikan elemen ini, sebaiknya ada peristiwa sebelum dan setelah animasi, untuk perubahan guna mengambil data atau mereset formulir dialog. Saya menggunakannya di sini untuk mengelola penambahan atribut inert pada dialog yang tertutup, dan dalam demo, saya menggunakannya untuk mengubah daftar avatar jika pengguna telah mengirimkan gambar baru.

Untuk melakukannya, buat dua acara baru bernama closing dan closed. Kemudian dengarkan peristiwa penutupan bawaan pada dialog. Dari sini, tetapkan dialog ke inert dan kirimkan peristiwa closing. Tugas berikutnya adalah menunggu animasi dan transisi selesai berjalan pada dialog, lalu mengirimkan peristiwa closed.

const dialogClosingEvent = new Event('closing')
const dialogClosedEvent  = new Event('closed')

export default async function (dialog) {
  
  dialog.addEventListener('close', dialogClose)
}

const dialogClose = async ({target:dialog}) => {
  dialog.setAttribute('inert', '')
  dialog.dispatchEvent(dialogClosingEvent)

  await animationsComplete(dialog)

  dialog.dispatchEvent(dialogClosedEvent)
}

const animationsComplete = element =>
  Promise.allSettled(
    element.getAnimations().map(animation => 
      animation.finished))

Fungsi animationsComplete, yang juga digunakan dalam Membangun komponen toast, menampilkan promise berdasarkan penyelesaian promise animasi dan transisi. Itulah sebabnya dialogClose adalah fungsi asinkron; fungsi ini kemudian dapat await Promise yang ditampilkan dan melanjutkan dengan yakin ke peristiwa tertutup.

Menambahkan acara pembukaan dan acara yang dibuka

Peristiwa ini tidak mudah ditambahkan karena elemen dialog bawaan tidak menyediakan peristiwa terbuka seperti yang dilakukan dengan peristiwa tertutup. Saya menggunakan MutationObserver untuk memberikan insight tentang perubahan atribut dialog. Dalam pengamat ini, saya akan memantau perubahan pada atribut terbuka dan mengelola peristiwa kustom dengan tepat.

Mirip dengan cara kita memulai penutupan dan menutup acara, buat dua acara baru yang disebut opening dan opened. Jika sebelumnya kita memantau peristiwa penutupan dialog, kali ini kita menggunakan pengamat mutasi yang dibuat untuk memantau atribut dialog.


const dialogOpeningEvent = new Event('opening')
const dialogOpenedEvent  = new Event('opened')

export default async function (dialog) {
  
  dialogAttrObserver.observe(dialog, { 
    attributes: true,
  })
}

const dialogAttrObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(async mutation => {
    if (mutation.attributeName === 'open') {
      const dialog = mutation.target

      const isOpen = dialog.hasAttribute('open')
      if (!isOpen) return

      dialog.removeAttribute('inert')

      // set focus
      const focusTarget = dialog.querySelector('[autofocus]')
      focusTarget
        ? focusTarget.focus()
        : dialog.querySelector('button').focus()

      dialog.dispatchEvent(dialogOpeningEvent)
      await animationsComplete(dialog)
      dialog.dispatchEvent(dialogOpenedEvent)
    }
  })
})

Fungsi callback pengamat mutasi akan dipanggil saat atribut dialog diubah, yang memberikan daftar perubahan sebagai array. Lakukan iterasi pada perubahan atribut, cari attributeName yang terbuka. Selanjutnya, periksa apakah elemen memiliki atribut atau tidak: hal ini memberi tahu apakah dialog telah terbuka atau tidak. Jika sudah dibuka, hapus atribut inert, tetapkan fokus ke elemen yang meminta autofocus atau elemen button pertama yang ditemukan dalam dialog. Terakhir, mirip dengan peristiwa penutupan dan tertutup, kirimkan peristiwa pembukaan segera, tunggu hingga animasi selesai, lalu kirimkan peristiwa dibuka.

Menambahkan acara yang dihapus

Dalam aplikasi web satu halaman, dialog sering ditambahkan dan dihapus berdasarkan rute atau kebutuhan dan status aplikasi lainnya. Hal ini dapat berguna untuk membersihkan peristiwa atau data saat dialog dihapus.

Anda dapat melakukannya dengan pengamat mutasi lain. Kali ini, alih-alih mengamati atribut pada elemen dialog, kita akan mengamati elemen turunan dari elemen body dan mengamati penghapusan elemen dialog.


const dialogRemovedEvent = new Event('removed')

export default async function (dialog) {
  
  dialogDeleteObserver.observe(document.body, {
    attributes: false,
    subtree: false,
    childList: true,
  })
}

const dialogDeleteObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(mutation => {
    mutation.removedNodes.forEach(removedNode => {
      if (removedNode.nodeName === 'DIALOG') {
        removedNode.removeEventListener('click', lightDismiss)
        removedNode.removeEventListener('close', dialogClose)
        removedNode.dispatchEvent(dialogRemovedEvent)
      }
    })
  })
})

Callback pengamat mutasi dipanggil setiap kali turunan ditambahkan atau dihapus dari isi dokumen. Mutasi spesifik yang diamati adalah untuk removedNodes yang memiliki nodeName dari dialog. Jika dialog dihapus, peristiwa klik dan tutup akan dihapus untuk mengosongkan memori, dan peristiwa dihapus kustom akan dikirim.

Menghapus atribut pemuatan

Untuk mencegah animasi dialog memutar animasi keluar saat ditambahkan ke halaman atau saat halaman dimuat, atribut pemuatan telah ditambahkan ke dialog. Skrip berikut menunggu hingga animasi dialog selesai berjalan, lalu menghapus atribut. Sekarang dialog dapat dianimasikan masuk dan keluar, dan kita telah menyembunyikan animasi yang mengganggu.

export default async function (dialog) {
  
  await animationsComplete(dialog)
  dialog.removeAttribute('loading')
}

Pelajari lebih lanjut masalah mencegah animasi keyframe saat pemuatan halaman di sini.

Bersama-sama

Berikut adalah dialog.js secara keseluruhan, setelah kita menjelaskan setiap bagiannya satu per satu:

// custom events to be added to <dialog>
const dialogClosingEvent = new Event('closing')
const dialogClosedEvent  = new Event('closed')
const dialogOpeningEvent = new Event('opening')
const dialogOpenedEvent  = new Event('opened')
const dialogRemovedEvent = new Event('removed')

// track opening
const dialogAttrObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(async mutation => {
    if (mutation.attributeName === 'open') {
      const dialog = mutation.target

      const isOpen = dialog.hasAttribute('open')
      if (!isOpen) return

      dialog.removeAttribute('inert')

      // set focus
      const focusTarget = dialog.querySelector('[autofocus]')
      focusTarget
        ? focusTarget.focus()
        : dialog.querySelector('button').focus()

      dialog.dispatchEvent(dialogOpeningEvent)
      await animationsComplete(dialog)
      dialog.dispatchEvent(dialogOpenedEvent)
    }
  })
})

// track deletion
const dialogDeleteObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(mutation => {
    mutation.removedNodes.forEach(removedNode => {
      if (removedNode.nodeName === 'DIALOG') {
        removedNode.removeEventListener('click', lightDismiss)
        removedNode.removeEventListener('close', dialogClose)
        removedNode.dispatchEvent(dialogRemovedEvent)
      }
    })
  })
})

// wait for all dialog animations to complete their promises
const animationsComplete = element =>
  Promise.allSettled(
    element.getAnimations().map(animation => 
      animation.finished))

// click outside the dialog handler
const lightDismiss = ({target:dialog}) => {
  if (dialog.nodeName === 'DIALOG')
    dialog.close('dismiss')
}

const dialogClose = async ({target:dialog}) => {
  dialog.setAttribute('inert', '')
  dialog.dispatchEvent(dialogClosingEvent)

  await animationsComplete(dialog)

  dialog.dispatchEvent(dialogClosedEvent)
}

// page load dialogs setup
export default async function (dialog) {
  dialog.addEventListener('click', lightDismiss)
  dialog.addEventListener('close', dialogClose)

  dialogAttrObserver.observe(dialog, { 
    attributes: true,
  })

  dialogDeleteObserver.observe(document.body, {
    attributes: false,
    subtree: false,
    childList: true,
  })

  // remove loading attribute
  // prevent page load @keyframes playing
  await animationsComplete(dialog)
  dialog.removeAttribute('loading')
}

Menggunakan modul dialog.js

Fungsi yang diekspor dari modul diharapkan dipanggil dan meneruskan elemen dialog yang ingin menambahkan peristiwa dan fungsi baru ini:

import GuiDialog from './dialog.js'

const MegaDialog = document.querySelector('#MegaDialog')
const MiniDialog = document.querySelector('#MiniDialog')

GuiDialog(MegaDialog)
GuiDialog(MiniDialog)

Dengan begitu, kedua dialog diupgrade dengan penutupan ringan, perbaikan pemuatan animasi, dan lebih banyak peristiwa untuk digunakan.

Memproses peristiwa kustom baru

Setiap elemen dialog yang diupgrade kini dapat memproses lima peristiwa baru, seperti ini:

MegaDialog.addEventListener('closing', dialogClosing)
MegaDialog.addEventListener('closed', dialogClosed)

MegaDialog.addEventListener('opening', dialogOpening)
MegaDialog.addEventListener('opened', dialogOpened)

MegaDialog.addEventListener('removed', dialogRemoved)

Berikut adalah dua contoh penanganan peristiwa tersebut:

const dialogOpening = ({target:dialog}) => {
  console.log('Dialog opening', dialog)
}

const dialogClosed = ({target:dialog}) => {
  console.log('Dialog closed', dialog)
  console.info('Dialog user action:', dialog.returnValue)

  if (dialog.returnValue === 'confirm') {
    // do stuff with the form values
    const dialogFormData = new FormData(dialog.querySelector('form'))
    console.info('Dialog form data', Object.fromEntries(dialogFormData.entries()))

    // then reset the form
    dialog.querySelector('form')?.reset()
  }
}

Dalam demo yang saya buat dengan elemen dialog, saya menggunakan peristiwa tertutup tersebut dan data formulir untuk menambahkan elemen avatar baru ke daftar. Waktunya tepat karena dialog telah menyelesaikan animasi keluarnya, lalu beberapa skrip menganimasikan avatar baru. Berkat peristiwa baru, mengatur pengalaman pengguna dapat menjadi lebih lancar.

Pemberitahuan dialog.returnValue: ini berisi string tutup yang diteruskan saat peristiwa dialog close() dipanggil. Hal ini penting dalam peristiwa dialogClosed untuk mengetahui apakah dialog ditutup, dibatalkan, atau dikonfirmasi. Jika dikonfirmasi, skrip akan mengambil nilai formulir dan mereset formulir. Reset ini berguna agar saat dialog ditampilkan lagi, dialog tersebut kosong dan siap untuk pengiriman baru.

Kesimpulan

Sekarang setelah Anda tahu cara saya melakukannya, bagaimana Anda‽ 🙂

Mari kita diversifikasi pendekatan kita dan pelajari semua cara untuk membangun di web.

Buat demo, tweet linknya kepada saya, dan saya akan menambahkannya ke bagian remix komunitas di bawah.

Remix komunitas

Resource