Membuat komponen dialog

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

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

Demonstrasi dialog mega dan mini dalam tema terang dan gelap.

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

Ringkasan

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

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

Dukungan Browser

  • 37
  • 79
  • 98
  • 15,4

Sumber

Saya menemukan bahwa elemen tidak memiliki beberapa hal, jadi dalam Tantangan GUI ini, saya menambahkan item pengalaman developer yang saya harapkan: peristiwa tambahan, penutupan ringan, 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>

Kami dapat meningkatkan garis dasar ini.

Secara tradisional, elemen dialog banyak berbagi dengan modal, dan sering kali namanya dapat dipertukarkan. Di sini saya membebaskan penggunaan elemen dialog untuk popup dialog kecil (mini), serta dialog halaman penuh (mega). Saya menamainya mega dan mini, dengan kedua dialog sedikit diadaptasi untuk kasus penggunaan yang berbeda. Saya telah 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 mega 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 digabungkan. Merupakan ide baik untuk memiliki 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 mega

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 saya merasa praktik terbaiknya adalah meletakkannya di tombol batal, bukan tombol konfirmasi. Hal ini memastikan bahwa konfirmasi dilakukan secara sengaja dan tidak disengaja.

Dialog mini

Dialog mininya sangat mirip dengan dialog mega, hanya saja tidak memiliki elemen <header>. Hal 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 yang kuat untuk elemen area tampilan penuh yang dapat mengumpulkan data dan interaksi pengguna. Hal-hal dasar ini dapat menciptakan interaksi yang sangat menarik dan efektif 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 di sana.

Memulihkan fokus

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

Elemen dialog ini merupakan 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 pada dokumen. Sebelum inert, JavaScript digunakan untuk mengamati fokus saat meninggalkan elemen, pada saat itu JavaScript mencegat dan menempatkannya kembali.

Dukungan Browser

  • 102
  • 102
  • 112
  • 15,5

Sumber

Setelah inert, setiap bagian dokumen dapat "dibekukan" sedemikian rupa sehingga tidak lagi menjadi target fokus atau interaktif dengan mouse. Alih-alih membatasi fokus, fokus akan diarahkan ke satu-satunya bagian interaktif dalam dokumen.

Membuka dan memfokuskan otomatis elemen

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

Menutup dengan tombol escape

Sangat penting untuk mempermudah penutupan elemen yang berpotensi mengganggu ini. Untungnya, elemen dialog akan menangani kunci escape untuk Anda, sehingga membebaskan Anda dari beban orkestrasi.

Gaya

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

Menata Gaya dengan Props Terbuka

Untuk mempercepat warna adaptif dan konsistensi desain secara keseluruhan, saya tanpa malu-malu membawa 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 display

Perilaku tampilkan dan sembunyikan default dari elemen dialog akan mengalihkan properti tampilan dari block menjadi none. Sayangnya, ini berarti animasi tidak dapat dianimasikan masuk dan keluar, hanya di dalam. Saya ingin menganimasikan masuk dan keluar, dan langkah pertamanya adalah menetapkan properti display saya sendiri:

dialog {
  display: grid;
}

Dengan mengubah, dan juga memiliki, nilai properti tampilan, seperti 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 saat tidak terbuka. Nanti, saya akan menambahkan beberapa JavaScript untuk mengelola atribut inert pada dialog, sehingga memastikan pengguna keyboard dan pembaca layar juga tidak dapat menjangkau dialog tersembunyi.

Memberikan tema warna adaptif pada dialog

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

Meskipun color-scheme mengikutsertakan dokumen Anda ke tema warna adaptif yang disediakan browser ke preferensi sistem terang dan gelap, saya ingin menyesuaikan elemen dialog lebih dari itu. Open Props menyediakan beberapa warna platform yang beradaptasi secara otomatis dengan preferensi sistem terang dan gelap, mirip dengan menggunakan color-scheme. Metode ini sangat bagus untuk membuat lapisan dalam desain dan saya suka menggunakan warna untuk mendukung tampilan permukaan lapisan ini secara visual. Warna latar belakang 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. Elemen ini sangat diperlukan untuk elemen dialog, tetapi sangat penting untuk membuat desain dialog yang menarik dan dirancang dengan baik.

Ukuran dialog responsif

Dialog ini secara default mendelegasikan ukurannya ke kontennya, yang umumnya besar. Sasaran saya di sini adalah membatasi max-inline-size pada ukuran yang dapat dibaca (--size-content-3 = 60ch) atau 90% dari lebar area tampilan. Hal ini memastikan dialog tidak akan melebar di perangkat seluler, dan tidak akan terlalu lebar pada layar desktop sehingga sulit dibaca. Kemudian, saya menambahkan max-block-size sehingga dialog tidak akan melebihi tinggi halaman. Ini juga berarti bahwa 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 ingin saya lakukan adalah mempertahankan dialog dalam alur relatif, untuk pengguna internasional, jadi saya menggunakan unit dvb yang logis, lebih baru, dan hanya didukung sebagian di deklarasi kedua saat menjadi lebih stabil.

Pemosisian dialog mega

Untuk membantu Anda memosisikan elemen dialog, ada baiknya menguraikan 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 pada tampilan latar ini secara bebas dan mengambil bentuk apa pun yang diperlukan kontennya.

Gaya berikut memperbaiki elemen dialog ke jendela, merentangkannya 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 pandang kecil, saya menata gaya modal besar halaman penuh ini dengan cara yang sedikit berbeda. Saya menyetel margin bawah ke 0, yang membawa konten dialog ke bagian bawah area pandang. Dengan beberapa penyesuaian gaya, saya bisa mengubah dialog menjadi actionsheet, 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 menempatkan spasi margin 
  di dialog mega desktop dan seluler saat terbuka.

Pemosisian dialog mini

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

Buat konten yang menarik

Terakhir, tambahkan beberapa gaya ke dialog sehingga terlihat seperti permukaan halus yang berada jauh di atas halaman. Kelembutan dicapai dengan membulatkan sudut dialog. Kedalamannya dapat dicapai dengan salah satu properti bayangan yang dibuat dengan cermat oleh Open Props:

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

Menyesuaikan elemen pseudo tampilan latar

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

Dukungan Browser

  • 76
  • 17
  • 103
  • 9

Sumber

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

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

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

Screenshot dialog mega yang dengan latar belakang buram avatar penuh warna.

Menata gaya tambahan

Saya menyebut bagian ini "tambahan" karena lebih berkaitan dengan demo elemen dialog 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 saya yang biasa, tetapi menurut spesifikasi, ini tidak berpengaruh pada dialog karena bukan port scroll, artinya, ini bukan scroller sehingga tidak ada yang perlu dicegah. Saya dapat menggunakan JavaScript untuk memantau peristiwa baru dari panduan ini, seperti "closed" dan "opened", serta mengaktifkan overflow: hidden pada dokumen, atau saya dapat menunggu :has() stabil di semua browser:

Dukungan Browser

  • 105
  • 105
  • 121
  • 15,4

Sumber

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

Sekarang ketika 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, {i>footer<i} dan artikel. Dengan tata letak ini, saya ingin menjabarkan 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. Menetapkan tinggi pasti dan ukuran baris pasti ini memungkinkan elemen artikel dibatasi dan dapat di-scroll saat 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. Tombol ini juga diberi warna permukaan agar tampak berada di belakang konten artikel dialog. Persyaratan ini menyebabkan container flexbox, item yang disejajarkan secara vertikal dan berjarak ke tepinya, serta beberapa padding dan celah untuk memberi 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 di header dialog.

Menata gaya tombol tutup header

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

Menata gaya dialog <article>

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

Untuk melakukannya, elemen formulir induk telah menetapkan beberapa batas maksimum untuk dirinya sendiri yang memberikan batasan untuk dicapai elemen artikel ini jika terlalu tinggi. Tetapkan overflow-y: auto sehingga scroll bar hanya ditampilkan saat diperlukan, berisi scroll 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 {i>footer<i} adalah berisi menu tombol tindakan. Flexbox digunakan untuk meratakan konten ke akhir sumbu sejajar footer, lalu beberapa spasi untuk memberikan 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 di elemen footer.

Elemen menu digunakan untuk memuat tombol tindakan untuk dialog. Fungsi ini menggunakan tata letak flexbox penggabungan dengan gap untuk memberikan ruang di antara tombol-tombol. Elemen menu memiliki padding seperti <ul>. Saya juga menghapus gaya 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 di elemen menu footer.

Animasi

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

Biasanya elemen dialog hanya bisa dianimasikan masuk, bukan keluar. Hal ini karena browser mengalihkan properti display pada elemen. Sebelumnya, panduan menyetelnya untuk ditampilkan ke petak, dan tidak pernah menetapkannya ke tidak ada. Hal ini membuka kemampuan untuk menganimasikan masuk dan keluar.

Open Props dilengkapi dengan banyak animasi keyframe untuk digunakan, yang membuat orkestrasi mudah dan dapat dibaca. Berikut adalah tujuan animasi dan pendekatan berlapis yang saya ambil:

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

Transisi default yang aman dan bermakna

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

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

Menambahkan gerakan ke transisi

Jika pengguna setuju dengan gerakan, dialog mega dan mini akan bergeser ke atas sebagai pintu masuk, dan diskalakan sebagai 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 seluler

Sebelumnya di bagian gaya visual, gaya dialog mega disesuaikan untuk perangkat seluler agar lebih mirip dengan lembar tindakan, seolah-olah selembar kertas kecil telah meluncur ke atas dari bagian bawah layar dan masih menempel di bagian bawah. Animasi keluar scale out kurang cocok 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 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 backdrop dialog), animasi, dan beberapa peristiwa tambahan untuk pengaturan waktu yang lebih baik dalam mendapatkan data formulir.

Menambahkan tutup ringan

Tugas ini mudah dan merupakan tambahan yang bagus untuk elemen dialog yang tidak dianimasikan. Interaksi dicapai dengan melihat klik pada elemen dialog dan memanfaatkan gelembung 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 disediakan. String ini dapat diambil oleh JavaScript lain untuk mendapatkan insight tentang bagaimana dialog ditutup. Anda akan menemukan bahwa saya juga telah menyediakan {i>close string<i} setiap kali memanggil fungsi dari berbagai tombol, untuk memberikan konteks ke aplikasi saya tentang interaksi pengguna.

Menambahkan peristiwa penutupan dan acara tertutup

Elemen dialog disertai dengan peristiwa tutup: elemen ini langsung muncul saat fungsi close() dialog dipanggil. Karena kita menganimasikan elemen ini, sebaiknya ada peristiwa sebelum dan setelah animasi, agar perubahan mengambil data atau mereset formulir 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. Itulah sebabnya dialogClose adalah fungsi asinkron; promise tersebut kemudian dapat await ditampilkan dan bergerak maju dengan percaya diri ke peristiwa yang ditutup.

Menambahkan peristiwa terbuka dan terbuka

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

Sama halnya dengan cara kita memulai peristiwa penutup dan peristiwa tertutup, buat dua peristiwa baru yang disebut opening dan opened. Jika sebelumnya kita memproses peristiwa tutup dialog, kali ini gunakan observer mutasi yang dibuat untuk mengamati 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 observer mutasi akan dipanggil saat atribut dialog diubah, yang menyediakan daftar perubahan sebagai array. Lakukan iterasi pada perubahan atribut, dengan mencari attributeName yang akan terbuka. Selanjutnya, periksa apakah elemen tersebut 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 peristiwa tertutup, segera kirim peristiwa pembuka, tunggu animasinya selesai, lalu kirim peristiwa yang dibuka.

Menambahkan peristiwa yang dihapus

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

Anda dapat melakukannya dengan observer mutasi lain. Kali ini, kita akan mengamati turunan elemen isi dan memperhatikan elemen dialog yang dihapus, bukan mengamati atribut pada 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 mutasi observer dipanggil setiap kali turunan ditambahkan atau dihapus dari isi dokumen. Mutasi spesifik yang dipantau adalah untuk removedNodes yang memiliki nodeName dialog. Jika dialog dihapus, peristiwa klik dan peristiwa tutup akan 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 dapat dianimasikan masuk dan keluar, dan kita 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 ini akan 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 tersebut 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 dan data formulir untuk menambahkan elemen avatar baru ke daftar. Pengaturan waktunya tepat karena dialog telah menyelesaikan animasi keluarnya, lalu beberapa skrip dianimasikan di avatar baru. Berkat peristiwa baru ini, orkestrasi pengalaman pengguna dapat lebih lancar.

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

Kesimpulan

Setelah Anda tahu cara saya melakukannya, bagaimana Anda‽ 🙂

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

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

Remix komunitas

Referensi