Membuat komponen dialog

Ringkasan dasar tentang cara membangun modal mini dan mega yang adaptif warna, responsif, serta dapat diakses dengan elemen <dialog>.

Dalam postingan ini, saya ingin berbagi pendapat tentang cara membangun modal mini dan mega yang adaptif warna, responsif, serta dapat diakses dengan elemen <dialog>. Coba demo dan lihat sumbernya.

Demonstrasi dialog mega dan mini dalam tema terang dan gelapnya.

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

Ringkasan

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

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

Dukungan Browser

  • 37
  • 79
  • 98
  • 15,4

Sumber

Ada beberapa elemen yang hilang, jadi dalam Tantangan GUI ini, saya menambahkan item pengalaman developer yang saya harapkan: event tambahan, penutupan lampu, animasi kustom, serta jenis mini dan mega.

Markup

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

<dialog>
  …
</dialog>

Kita dapat meningkatkan garis dasar ini.

Biasanya, elemen dialog sering berbagi dengan modal, dan sering kali namanya dapat dipertukarkan. Di sini saya bebas menggunakan elemen dialog untuk pop-up dialog kecil (mini), serta dialog halaman penuh (mega). Saya menamainya mega dan mini, dan kedua dialog itu sedikit disesuaikan untuk kasus penggunaan yang berbeda. Saya menambahkan atribut modal-mode agar Anda dapat menentukan jenis:

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

Screenshot dialog mini dan besar dalam tema terang dan gelap.

Tidak selalu, tetapi umumnya elemen dialog akan digunakan untuk mengumpulkan beberapa informasi interaksi. Formulir di dalam elemen dialog dibuat untuk saling melengkapi. Sebaiknya sediakan elemen formulir yang menggabungkan konten dialog Anda sehingga 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>. Keduanya berfungsi sebagai penampung semantik, serta target gaya untuk presentasi dialog. {i>Header<i} memberi judul modal dan menawarkan tombol {i>close<i}. Artikel ini ditujukan untuk informasi dan input formulir. Footer menyimpan <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 menempatkannya di tombol batal, bukan tombol konfirmasi. Hal ini memastikan bahwa konfirmasi dilakukan secara sengaja dan bukan tidak disengaja.

Dialog mini

Dialog mini sangat mirip dengan dialog mega, hanya saja elemen <header> tidak ada. Ini memungkinkannya menjadi lebih kecil dan lebih inline.

<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 kuat untuk elemen area pandang penuh yang dapat mengumpulkan data dan interaksi pengguna. Hal-hal penting ini dapat menghasilkan beberapa interaksi yang sangat menarik dan kuat di situs atau aplikasi Anda.

Aksesibilitas

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

Memulihkan fokus

Seperti yang kita lakukan secara manual dalam Membuat komponen sidenav, penting bahwa membuka dan menutup sesuatu dengan benar akan berfokus pada tombol buka dan tutup yang relevan. Ketika navigasi samping tersebut terbuka, fokus ditempatkan pada tombol tutup. Saat tombol tutup ditekan, fokus akan dipulihkan ke tombol yang membukanya.

Elemen dialog 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.

Menjebak fokus

Elemen dialog mengelola inert untuk Anda di dokumen. Sebelum inert, JavaScript digunakan untuk mengamati fokus saat meninggalkan elemen, yang pada titik ini akan diintersepsi dan dimunculkan kembali.

Dukungan Browser

  • 102
  • 102
  • 112
  • 15,5

Sumber

Setelah inert, bagian apa pun dari dokumen dapat "dibekukan" sehingga tidak lagi menjadi target fokus atau interaktif dengan mouse. Alih-alih menjebak fokus, fokus akan dipandu ke satu-satunya bagian interaktif pada dokumen.

Membuka dan otomatis memfokuskan elemen

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

Menutup dengan tombol escape

Sebaiknya, permudah aktivitas ini untuk menutup elemen yang berpotensi mengganggu. Untungnya, elemen dialog akan menangani tombol escape untuk Anda, sehingga membebaskan Anda dari beban orkestrasi.

Gaya

Ada jalur yang mudah untuk menata gaya elemen dialog dan jalur yang sulit. Jalur yang mudah dicapai dengan tidak mengubah properti tampilan dialog dan menangani batasannya. Saya menempuh jalur yang sulit guna menyediakan animasi kustom untuk membuka dan menutup dialog, mengambil alih properti display dan banyak lagi.

Menata Gaya dengan Progres Terbuka

Untuk mempercepat warna adaptif dan konsistensi desain secara keseluruhan, saya dengan malu-malu menggunakan library variabel CSS Open Props. Selain variabel yang disediakan gratis, saya juga mengimpor file normalisasi dan beberapa tombol, yang keduanya disediakan oleh 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 tampilkan dan sembunyikan default elemen dialog mengalihkan properti tampilan dari block menjadi none. Sayangnya, ini tidak bisa dianimasikan hanya ke dalam dan ke dalam. Saya ingin menganimasikan baik ke dalam maupun ke luar, dan langkah pertama adalah menetapkan properti display saya sendiri:

dialog {
  display: grid;
}

Dengan mengubah sekaligus 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 ditutup. 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 jika tidak dibuka. Nanti, saya akan menambahkan beberapa JavaScript untuk mengelola atribut inert pada dialog, yang memastikan pengguna keyboard dan pembaca layar juga tidak dapat menjangkau dialog tersembunyi.

Memberikan tema warna adaptif kepada dialog

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

Meskipun color-scheme mengikutsertakan dokumen Anda ke tema warna adaptif yang disediakan browser terhadap preferensi sistem terang dan gelap, saya ingin lebih menyesuaikan elemen dialog dari itu. Progres Terbuka menyediakan beberapa warna permukaan yang otomatis beradaptasi dengan preferensi sistem terang dan gelap, mirip dengan menggunakan color-scheme. Alat ini sangat bagus 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 adaptif lainnya akan ditambahkan nanti untuk elemen turunan, seperti header dan footer. Saya menganggapnya sebagai elemen dialog, tetapi sangat penting dalam membuat desain dialog yang menarik dan dirancang dengan baik.

Ukuran dialog responsif

Secara default, dialog mendelegasikan ukurannya sesuai dengan kontennya, yang umumnya sudah bagus. Tujuan saya di sini adalah membatasi max-inline-size ke ukuran yang dapat dibaca (--size-content-3 = 60ch) atau 90% dari lebar area tampilan. Hal ini memastikan dialog tidak akan ditampilkan secara menyeluruh di perangkat seluler, dan tidak akan terlalu lebar di layar desktop sehingga sulit dibaca. Lalu saya menambahkan max-block-size sehingga dialog tidak akan melebihi tinggi halaman. Ini juga berarti kita harus menentukan lokasi dialog yang dapat di-scroll, jika berupa elemen dialog yang 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 bahwa saya memiliki max-block-size dua kali? Yang pertama menggunakan 80vh, unit area pandang fisik. Yang benar-benar 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 deklarasi kedua menjadi lebih stabil.

Penempatan dialog mega

Untuk membantu memosisikan elemen dialog, sebaiknya bagi dua bagiannya: tampilan latar layar penuh dan penampung dialog. Tampilan latar harus mencakup semuanya, memberikan efek bayangan untuk membantu mendukung bahwa dialog ini berada di depan dan konten di belakang tidak dapat diakses. Penampung dialog dapat dipusatkan di atas tampilan latar ini secara bebas dan dalam bentuk apa pun yang dibutuhkan isinya.

Gaya berikut memperbaiki elemen dialog ke jendela, merentangnya 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 tampilan yang terlihat kecil, saya memberi gaya pada megamodal halaman penuh ini sedikit berbeda. Saya menyetel margin bawah ke 0, yang membawa konten dialog ke bagian bawah area tampilan. Dengan beberapa penyesuaian gaya, saya bisa 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 overlay spasi margin 
  di dialog besar desktop dan seluler saat terbuka.

Penempatan dialog mini

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

Buat yang menarik

Terakhir, tambahkan gaya ke dialog sehingga terlihat seperti permukaan empuk yang berada jauh di atas halaman. Kelembutan dicapai dengan membulatkan sudut dialog. Kedalaman dicapai dengan salah satu propagasi bayangan Open Prop yang dibuat dengan cermat:

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

Menyesuaikan elemen semu tampilan latar

Saya memilih untuk bekerja sangat ringan dengan tampilan latar, hanya menambahkan efek buram dengan backdrop-filter ke dialog mega:

Dukungan Browser

  • 76
  • 79
  • 103
  • 9

Sumber

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

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

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

Screenshot dialog besar yang menempatkan latar belakang buram avatar berwarna-warni.

Tambahan gaya visual

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

Pembatasan scroll

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

Biasanya, overscroll-behavior akan menjadi solusi biasa, tetapi sesuai dengan spesifikasi, hal ini tidak berpengaruh pada dialog karena ini bukan port scroll, yaitu bukan scroller sehingga tidak ada yang perlu dicegah. Saya dapat menggunakan JavaScript untuk memantau peristiwa baru dari panduan ini, seperti "ditutup" dan "dibuka", dan mengaktifkan/menonaktifkan overflow: hidden pada dokumen, atau saya dapat menunggu hingga :has() stabil di semua browser:

Dukungan Browser

  • 105
  • 105
  • 121
  • 15,4

Sumber

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 ingin menetapkan turunan artikel sebagai area yang dapat di-scroll. Saya mencapainya dengan grid-template-rows. Elemen artikel diberi 1fr dan formulir itu sendiri memiliki tinggi maksimum yang sama dengan elemen dialog. Dengan menetapkan tinggi dan ukuran baris yang tegas ini, elemen artikel dapat dibatasi dan dapat di-scroll jika melebihi:

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

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

Menata gaya dialog <header>

Peran elemen ini adalah memberikan judul untuk konten dialog dan menawarkan tombol tutup yang mudah ditemukan. Panel ini juga diberi warna permukaan agar tampak berada di belakang konten artikel dialog. Persyaratan ini menyebabkan penampung flexbox, item yang disejajarkan secara vertikal dengan jarak ke tepinya, dan beberapa padding dan celah untuk memberikan ruang pada 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 menempatkan informasi tata letak flexbox pada header dialog.

Menata gaya tombol tutup header

Karena demo ini menggunakan tombol Open Props, tombol tutup disesuaikan menjadi tombol yang berfokus 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 menempatkan informasi ukuran dan padding untuk tombol tutup header.

Menata gaya dialog <article>

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

Untuk melakukannya, elemen formulir induk telah menetapkan batas maksimum untuk dirinya sendiri yang memberikan batasan untuk dicapai elemen artikel ini jika terlalu tinggi. Setel overflow-y: auto sehingga scrollbar hanya ditampilkan saat diperlukan, berisi scroll di dalamnya dengan overscroll-behavior: contain, dan sisanya akan berupa 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 {i>footer<i} adalah untuk berisi menu tombol tindakan. Flexbox digunakan untuk meratakan konten dengan akhir sumbu inline footer, lalu memberikan beberapa spasi 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 menempatkan informasi tata letak flexbox pada elemen footer.

Elemen menu digunakan untuk memuat tombol tindakan untuk dialog. Fungsi ini menggunakan tata letak flexbox pembungkus dengan gap untuk menyediakan ruang di antara tombol. Elemen menu memiliki padding seperti <ul>. Saya juga menghapus {i>style<i} itu karena saya tidak membutuhkannya.

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 menempatkan informasi flexbox pada elemen menu footer.

Animasi

Elemen dialog sering dianimasikan karena masuk dan keluar dari jendela. Memberikan dialog beberapa gerakan yang mendukung untuk masuk dan keluar ini akan membantu pengguna mengorientasikan diri mereka dalam alur.

Biasanya elemen dialog hanya dapat dianimasikan masuk, bukan keluar. Hal ini karena browser mengalihkan properti display pada elemen. Sebelumnya, panduan menyetel tampilan ke petak, dan tidak pernah menyetelnya ke tidak ada. Ini membuka kemampuan untuk menganimasikan masuk dan keluarnya video.

Proposisi Terbuka dilengkapi dengan banyak animasi frame utama untuk digunakan, sehingga orkestrasi mudah dan dapat dibaca. Berikut adalah tujuan animasi dan pendekatan berlapis yang saya ambil:

  1. Gerakan yang dikurangi adalah transisi default, opasitas sederhana yang perlahan semakin jelas.
  2. Jika gerakan diperbolehkan, animasi {i>slide<i} dan skala ditambahkan.
  3. Tata letak seluler yang responsif untuk dialog besar disesuaikan agar dapat digeser keluar.

Transisi default yang aman dan bermakna

Meskipun Progres Terbuka dilengkapi dengan keyframe untuk fading masuk dan keluar, saya lebih menyukai pendekatan transisi berlapis ini sebagai default dengan animasi keyframe sebagai potensi upgrade. Sebelumnya, kita sudah menata gaya visibilitas dialog dengan opasitas, mengorkestrasi 1 atau 0, bergantung pada atribut [open]. Untuk transisi antara 0% dan 100%, beri tahu browser durasi dan jenis easing apa yang Anda inginkan:

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

Menambahkan {i>motion <i}pada transisi

Jika pengguna menyetujui gerakan, dialog mega dan mini harus bergeser ke atas sebagai pintu masuk, dan menskalakan saat keluar. Anda dapat melakukannya dengan kueri media prefers-reduced-motion dan beberapa Progres Terbuka:

@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 visual, gaya dialog mega disesuaikan untuk perangkat seluler agar lebih seperti lembar tindakan, seolah-olah selembar kertas kecil telah meluncur ke atas dari bagian bawah layar dan masih menempel ke bawah. Animasi keluar penyebaran skala tidak cocok dengan desain baru ini, dan kita dapat menyesuaikannya dengan beberapa kueri media dan beberapa Proposisi Terbuka:

@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 perlu 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 cahaya (mengklik tampilan latar dialog), animasi, dan beberapa peristiwa tambahan untuk waktu yang lebih baik dalam mendapatkan data formulir.

Menambahkan tutup terang

Tugas ini mudah dilakukan dan merupakan tambahan yang bagus untuk elemen dialog yang tidak dianimasikan. Interaksi dicapai dengan melihat klik pada elemen dialog dan memanfaatkan peristiwa bubble untuk menilai apa yang diklik, dan hanya akan close() jika itu 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 disediakan. String ini dapat diambil oleh JavaScript lain untuk mendapatkan insight tentang cara dialog ditutup. Saya juga telah menyediakan string close setiap kali memanggil fungsi dari berbagai tombol, untuk memberikan konteks ke aplikasi saya tentang interaksi pengguna.

Menambahkan acara penutupan dan acara yang ditutup

Elemen dialog disertai peristiwa tutup: peristiwa tersebut langsung muncul saat fungsi close() dialog dipanggil. Karena kita menganimasikan elemen ini, sebaiknya Anda memiliki peristiwa sebelum dan sesudah animasi, untuk perubahan guna mengambil data atau mereset bentuk dialog. Saya menggunakannya di sini untuk mengelola penambahan atribut inert pada dialog tertutup, dan dalam demo saya menggunakannya untuk mengubah daftar avatar jika pengguna telah mengirimkan gambar baru.

Untuk melakukannya, buat dua peristiwa baru bernama closing dan closed. Kemudian proses peristiwa tutup bawaan pada dialog. Dari sini, setel dialog ke inert dan kirim peristiwa closing. Tugas berikutnya adalah menunggu animasi dan transisi selesai berjalan pada dialog, lalu mengirim 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 Membuat komponen toast, menampilkan promise berdasarkan penyelesaian animasi dan promise transisi. Inilah alasan mengapa dialogClose adalah fungsi asinkron; kemudian dapat await janji yang ditampilkan dan bergerak maju tanpa ragu ke peristiwa tertutup.

Menambahkan acara pembukaan dan yang dibuka

Peristiwa ini tidak mudah ditambahkan karena elemen dialog bawaan tidak menyediakan peristiwa terbuka seperti pada penutupan. Saya menggunakan MutationObserver untuk memberikan insight tentang perubahan atribut dialog. Dalam observer ini, saya akan mengamati perubahan pada atribut buka dan mengelola peristiwa kustom sebagaimana mestinya.

Serupa dengan cara kami memulai peristiwa penutupan dan penutupan, buat dua peristiwa baru yang disebut opening dan opened. Jika sebelumnya kita memproses peristiwa penutupan dialog, kali ini gunakan observer mutasi yang dibuat untuk melihat 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 saat perubahan atribut dilakukan, agar attributeName terbuka. Selanjutnya, periksa apakah elemen memiliki atribut atau tidak: ini memberi tahu apakah dialog telah terbuka atau tidak. Jika telah 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, langsung mengirim peristiwa pembuka, tunggu hingga animasi selesai, lalu kirim peristiwa yang dibuka.

Menambahkan acara yang dihapus

Dalam aplikasi web satu halaman, dialog sering ditambahkan dan dihapus berdasarkan rute atau kebutuhan dan status aplikasi lainnya. Sebaiknya bersihkan 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 turunan elemen isi dan memperhatikan elemen dialog yang dihapus.

…
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 sedang dipantau adalah untuk removedNodes yang memiliki nodeName dialog. Jika dialog dihapus, peristiwa klik dan tutup dihapus untuk mengosongkan memori, dan peristiwa kustom yang dihapus akan dikirim.

Menghapus atribut pemuatan

Untuk mencegah animasi dialog memutar animasi keluarnya saat ditambahkan ke halaman atau saat pemuatan halaman, atribut pemuatan telah ditambahkan ke dialog. Skrip berikut menunggu animasi dialog selesai berjalan, lalu menghapus atribut. Sekarang dialog bebas dianimasikan masuk dan keluar, dan kami telah secara efektif 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.

Semuanya

Berikut adalah dialog.js secara keseluruhan, setelah kami menjelaskan setiap bagian 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 mengharapkan untuk dipanggil dan meneruskan elemen dialog yang ingin ditambahi 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 tersebut diupgrade dengan penutupan ringan, perbaikan pemuatan animasi, dan peristiwa lainnya yang dapat 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 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 dibuat dengan elemen dialog, saya menggunakan peristiwa tertutup tersebut dan data formulir untuk menambahkan elemen avatar baru ke daftar. Waktunya tepat jika dialog telah menyelesaikan animasi keluarnya, lalu beberapa skrip dianimasikan dalam avatar baru. Berkat peristiwa baru ini, orkestrasi pengalaman pengguna dapat lebih lancar.

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

Kesimpulan

Sekarang setelah Anda tahu bagaimana saya melakukannya, bagaimana Anda akan 🙂

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

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

Remix komunitas

Referensi