Membuat Dialog yang Dapat Diakses Dari Awal
Diterbitkan: 2022-03-10Pertama-tama, jangan lakukan ini di rumah. Jangan menulis dialog Anda sendiri atau perpustakaan untuk melakukannya. Ada banyak dari mereka di luar sana yang telah diuji, diaudit, digunakan dan digunakan kembali dan Anda harus memilih yang ini daripada milik Anda sendiri. a11y-dialog adalah salah satunya, tetapi ada lebih banyak lagi (tercantum di akhir artikel ini).
Izinkan saya mengambil posting ini sebagai kesempatan untuk mengingatkan Anda semua untuk berhati-hati saat menggunakan dialog . Sangat menarik untuk mengatasi semua masalah desain dengan mereka, terutama di ponsel, tetapi sering kali ada cara lain untuk mengatasi masalah desain. Kita cenderung cepat jatuh ke dalam menggunakan dialog bukan karena mereka selalu merupakan pilihan yang tepat tetapi karena mereka mudah. Mereka mengesampingkan masalah layar dengan memperdagangkannya untuk pengalihan konteks, yang tidak selalu merupakan pertukaran yang tepat. Intinya adalah: pertimbangkan apakah sebuah dialog adalah pola desain yang tepat sebelum menggunakannya.
Dalam posting ini, kita akan menulis library JavaScript kecil untuk membuat dialog yang dapat diakses dari awal (pada dasarnya membuat ulang dialog a11y). Tujuannya adalah untuk memahami apa yang ada di dalamnya. Kami tidak akan berurusan dengan penataan terlalu banyak, hanya bagian JavaScript. Kami akan menggunakan JavaScript modern demi kesederhanaan (seperti kelas dan fungsi panah), tetapi perlu diingat bahwa kode ini mungkin tidak berfungsi di browser lawas.
- Mendefinisikan API
- Membuat dialog
- Menampilkan dan menyembunyikan
- Menutup dengan overlay
- Menutup dengan melarikan diri
- Menjebak fokus
- Mempertahankan fokus
- Memulihkan fokus
- Memberi nama yang dapat diakses
- Menangani acara khusus
- Membersihkan
- Satukan semuanya
- Membungkus
Mendefinisikan API
Pertama, kami ingin menentukan bagaimana kami akan menggunakan skrip dialog kami. Kami akan membuatnya sesederhana mungkin untuk memulai. Kami memberikan elemen HTML root untuk dialog kami, dan contoh yang kami dapatkan memiliki metode .show(..)
dan .hide(..)
.
class Dialog { constructor(element) {} show() {} hide() {} }
Membuat Instansi Dialog
Katakanlah kita memiliki HTML berikut:
<div>This will be a dialog.</div>
Dan kami instantiate dialog kami seperti ini:
const element = document.querySelector('#my-dialog') const dialog = new Dialog(element)
Ada beberapa hal yang perlu kita lakukan saat membuat instance:
- Sembunyikan sehingga disembunyikan secara default (
hidden
). - Tandai sebagai dialog untuk teknologi bantu (
role="dialog"
). - Jadikan sisa halaman tidak aktif saat dibuka (
aria-modal="true"
).
constructor (element) { // Store a reference to the HTML element on the instance so it can be used // across methods. this.element = element this.element.setAttribute('hidden', true) this.element.setAttribute('role', 'dialog') this.element.setAttribute('aria-modal', true) }
Perhatikan bahwa kita dapat menambahkan 3 atribut ini dalam HTML awal kita agar tidak harus menambahkannya dengan JavaScript, tetapi cara ini tidak terlihat, tidak terpikirkan. Skrip kami dapat memastikan segala sesuatunya akan berfungsi sebagaimana mestinya, terlepas dari apakah kami telah berpikir untuk menambahkan semua atribut kami atau tidak.
Menampilkan Dan Menyembunyikan
Kami memiliki dua metode: satu untuk menampilkan dialog dan satu lagi untuk menyembunyikannya. Metode ini tidak akan berbuat banyak (untuk saat ini) selain mengaktifkan atribut hidden
pada elemen root. Kami juga akan mempertahankan boolean pada instance agar dapat dengan cepat menilai apakah dialog ditampilkan atau tidak. Ini akan berguna nanti.
show() { this.isShown = true this.element.removeAttribute('hidden') } hide() { this.isShown = false this.element.setAttribute('hidden', true) }
Untuk menghindari dialog terlihat sebelum JavaScript masuk dan menyembunyikannya dengan menambahkan atribut, mungkin menarik untuk menambahkan hidden
ke dialog langsung di HTML sejak awal.
<div hidden>This will be a dialog.</div>
Menutup Dengan Hamparan
Mengklik di luar dialog akan menutupnya. Ada beberapa cara untuk melakukannya. Salah satu caranya adalah dengan mendengarkan semua peristiwa klik pada halaman dan memfilter yang terjadi di dalam dialog, tetapi itu relatif rumit untuk dilakukan.
Pendekatan lain adalah mendengarkan acara klik pada overlay (terkadang disebut "latar belakang"). Overlay itu sendiri bisa sesederhana <div>
dengan beberapa gaya.
Jadi saat membuka dialog, kita perlu mengikat event klik pada overlay. Kita bisa memberikan ID atau kelas tertentu untuk dapat menanyakannya, atau kita bisa memberinya atribut data. Saya cenderung menyukai ini untuk kait perilaku. Mari kita ubah HTML kita sesuai dengan itu:
<div hidden> <div data-dialog-hide></div> <div>This will be a dialog.</div> </div>
Sekarang, kita dapat mengkueri elemen dengan atribut data-dialog-hide
di dalam dialog dan memberi mereka pendengar klik yang menyembunyikan dialog.
constructor (element) { // … rest of the code // Bind our methods so they can be used in event listeners without losing the // reference to the dialog instance this._show = this.show.bind(this) this._hide = this.hide.bind(this) const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.addEventListener('click', this._hide)) }
Hal yang menyenangkan tentang memiliki sesuatu yang cukup umum seperti ini adalah kita juga dapat menggunakan hal yang sama untuk tombol tutup dialog.
<div hidden> <div data-dialog-hide></div> <div> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>
Menutup Dengan Melarikan Diri
Dialog tidak hanya harus disembunyikan saat mengklik di luarnya, tetapi juga harus disembunyikan saat menekan Esc . Saat membuka dialog, kita dapat mengikat pendengar keyboard ke dokumen, dan menghapusnya saat menutupnya. Dengan cara ini, ia hanya mendengarkan penekanan tombol saat dialog terbuka, bukan sepanjang waktu.
show() { // … rest of the code // Note: `_handleKeyDown` is the bound method, like we did for `_show`/`_hide` document.addEventListener('keydown', this._handleKeyDown) } hide() { // … rest of the code // Note: `_handleKeyDown` is the bound method, like we did for `_show`/`_hide` document.removeEventListener('keydown', this._handleKeyDown) } handleKeyDown(event) { if (event.key === 'Escape') this.hide() }
Menjebak Fokus
Sekarang itu hal yang baik. Menjebak fokus di dalam dialog adalah inti dari semuanya, dan harus menjadi bagian yang paling rumit (walaupun mungkin tidak serumit yang Anda kira).
Idenya cukup sederhana: ketika dialog terbuka, kami mendengarkan penekanan Tab . Jika menekan Tab pada elemen terakhir yang dapat difokuskan dari dialog, kami secara terprogram memindahkan fokus ke yang pertama. Jika menekan Shift + Tab pada elemen pertama yang dapat difokuskan dari dialog, kami memindahkannya ke yang terakhir.
Fungsinya mungkin terlihat seperti ini:
function trapTabKey(node, event) { const focusableChildren = getFocusableChildren(node) const focusedItemIndex = focusableChildren.indexOf(document.activeElement) const lastIndex = focusableChildren.length - 1 const withShift = event.shiftKey if (withShift && focusedItemIndex === 0) { focusableChildren[lastIndex].focus() event.preventDefault() } else if (!withShift && focusedItemIndex === lastIndex) { focusableChildren[0].focus() event.preventDefault() } }
Hal berikutnya yang perlu kita ketahui adalah bagaimana mendapatkan semua elemen dialog yang dapat difokuskan ( getFocusableChildren
). Kita perlu menanyakan semua elemen yang secara teoritis dapat difokuskan, dan kemudian kita perlu memastikannya secara efektif.
Bagian pertama dapat dilakukan dengan pemilih yang dapat difokuskan. Ini adalah paket kecil mungil yang saya tulis yang menyediakan array penyeleksi ini:
module.exports = [ 'a[href]:not([tabindex^="-"])', 'area[href]:not([tabindex^="-"])', 'input:not([type="hidden"]):not([type="radio"]):not([disabled]):not([tabindex^="-"])', 'input[type="radio"]:not([disabled]):not([tabindex^="-"]):checked', 'select:not([disabled]):not([tabindex^="-"])', 'textarea:not([disabled]):not([tabindex^="-"])', 'button:not([disabled]):not([tabindex^="-"])', 'iframe:not([tabindex^="-"])', 'audio[controls]:not([tabindex^="-"])', 'video[controls]:not([tabindex^="-"])', '[contenteditable]:not([tabindex^="-"])', '[tabindex]:not([tabindex^="-"])', ]
Dan ini cukup untuk membuat Anda 99% di sana. Kita dapat menggunakan selektor ini untuk menemukan semua elemen yang dapat difokuskan, dan kemudian kita dapat memeriksa setiap elemen untuk memastikannya benar-benar terlihat di layar (dan tidak disembunyikan atau semacamnya).
import focusableSelectors from 'focusable-selectors' function isVisible(element) { return element => element.offsetWidth || element.offsetHeight || element.getClientRects().length } function getFocusableChildren(root) { const elements = [...root.querySelectorAll(focusableSelectors.join(','))] return elements.filter(isVisible) }
Kami sekarang dapat memperbarui metode handleKeyDown
kami:
handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) }
Mempertahankan Fokus
Satu hal yang sering diabaikan saat membuat dialog yang dapat diakses adalah memastikan fokus tetap berada di dalam dialog bahkan setelah halaman kehilangan fokus. Pikirkan seperti ini: apa yang terjadi jika setelah dialog terbuka? Kami memfokuskan bilah URL browser, dan kemudian mulai tab lagi. Perangkap fokus kami tidak akan berfungsi, karena hanya mempertahankan fokus di dalam dialog saat itu di dalam dialog untuk memulai.
Untuk memperbaiki masalah itu, kita dapat mengikat pendengar fokus ke elemen <body>
saat dialog ditampilkan, dan memindahkan fokus ke elemen pertama yang dapat difokuskan di dalam dialog.
show () { // … rest of the code // Note: `_maintainFocus` is the bound method, like we did for `_show`/`_hide` document.body.addEventListener('focus', this._maintainFocus, true) } hide () { // … rest of the code // Note: `_maintainFocus` is the bound method, like we did for `_show`/`_hide` document.body.removeEventListener('focus', this._maintainFocus, true) } maintainFocus(event) { const isInDialog = event.target.closest('[aria-modal="true"]') if (!isInDialog) this.moveFocusIn() } moveFocusIn () { const target = this.element.querySelector('[autofocus]') || getFocusableChildren(this.element)[0] if (target) target.focus() }
Elemen mana yang menjadi fokus saat membuka dialog tidak diterapkan, dan itu bisa bergantung pada jenis konten yang ditampilkan dialog. Secara umum, ada beberapa opsi:
- Fokuskan elemen pertama.
Inilah yang kami lakukan di sini, karena menjadi lebih mudah dengan fakta bahwa kami telah memiliki fungsigetFocusableChildren
. - Fokuskan tombol tutup.
Ini juga merupakan solusi yang baik, terutama jika tombol benar-benar diposisikan relatif terhadap dialog. Kita dapat dengan mudah mewujudkannya dengan menempatkan tombol tutup kita sebagai elemen pertama dari dialog kita. Jika tombol tutup hidup dalam aliran konten dialog, di bagian paling akhir, itu bisa menjadi masalah jika dialog memiliki banyak konten (dan karenanya dapat digulir), karena akan menggulir konten hingga akhir saat terbuka. - Fokuskan dialog itu sendiri .
Ini tidak terlalu umum di antara pustaka dialog, tetapi juga harus berfungsi (walaupun perlu menambahkantabindex="-1"
ke dalamnya sehingga dimungkinkan karena elemen<div>
tidak dapat difokuskan secara default).
Perhatikan bahwa kami memeriksa apakah ada elemen dengan atribut HTML autofocus
dalam dialog, dalam hal ini kami akan memindahkan fokus ke sana alih-alih item pertama.
Memulihkan Fokus
Kami telah berhasil menjebak fokus di dalam dialog, tetapi kami lupa memindahkan fokus ke dalam dialog setelah terbuka. Demikian pula, kita perlu mengembalikan fokus kembali ke elemen yang memilikinya sebelum dialog terbuka.
Saat menampilkan dialog, kita bisa mulai dengan menyimpan referensi ke elemen yang memiliki fokus ( document.activeElement
). Sebagian besar waktu, ini akan menjadi tombol yang berinteraksi dengan untuk membuka dialog, tetapi dalam kasus yang jarang terjadi di mana dialog dibuka secara terprogram, itu bisa menjadi sesuatu yang lain.
show() { this.previouslyFocused = document.activeElement // … rest of the code this.moveFocusIn() }
Saat menyembunyikan dialog, kita dapat memindahkan fokus kembali ke elemen tersebut. Kami menjaganya dengan kondisi untuk menghindari kesalahan JavaScript jika elemen tersebut entah bagaimana tidak ada lagi (atau jika itu adalah SVG):
hide() { // … rest of the code if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } }
Memberi Nama yang Dapat Diakses
Penting agar dialog kita memiliki nama yang dapat diakses, yang akan dicantumkan dalam pohon aksesibilitas. Ada beberapa cara untuk mengatasinya, salah satunya adalah dengan mendefinisikan nama di atribut aria-label
, tetapi aria-label
memiliki masalah.
Cara lain adalah dengan memiliki judul dalam dialog kita (apakah tersembunyi atau tidak), dan mengaitkan dialog kita dengannya dengan atribut aria-labelledby
. Ini mungkin terlihat seperti ini:
<div hidden aria-labelledby="my-dialog-title"> <div data-dialog-hide></div> <div> <h1>My dialog title</h1> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>
Saya kira kita bisa membuat skrip kita menerapkan atribut ini secara dinamis berdasarkan keberadaan judul dan yang lainnya, tetapi saya akan mengatakan ini dengan mudah diselesaikan dengan menulis HTML yang tepat, untuk memulai. Tidak perlu menambahkan JavaScript untuk itu.
Menangani Acara Khusus
Bagaimana jika kita ingin bereaksi terhadap dialog yang terbuka? Atau ditutup? Saat ini tidak ada cara untuk melakukannya, tetapi menambahkan sistem acara kecil seharusnya tidak terlalu sulit. Kita membutuhkan fungsi untuk mendaftarkan peristiwa (sebut saja .on(..)
), dan fungsi untuk membatalkan pendaftarannya ( .off(..)
).
class Dialog { constructor(element) { this.events = { show: [], hide: [] } } on(type, fn) { this.events[type].push(fn) } off(type, fn) { const index = this.events[type].indexOf(fn) if (index > -1) this.events[type].splice(index, 1) } }
Kemudian ketika menampilkan dan menyembunyikan metode, kita akan memanggil semua fungsi yang telah didaftarkan untuk event tertentu.
class Dialog { show() { // … rest of the code this.events.show.forEach(event => event()) } hide() { // … rest of the code this.events.hide.forEach(event => event()) } }
Membersihkan
Kami mungkin ingin memberikan metode untuk membersihkan dialog jika kami sudah selesai menggunakannya. Itu akan bertanggung jawab untuk membatalkan pendaftaran pendengar acara sehingga mereka tidak bertahan lebih dari yang seharusnya.
class Dialog { destroy() { const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.removeEventListener('click', this._hide)) this.events.show.forEach(event => this.off('show', event)) this.events.hide.forEach(event => this.off('hide', event)) } }
Menyatukan Semuanya
import focusableSelectors from 'focusable-selectors' class Dialog { constructor(element) { this.element = element this.events = { show: [], hide: [] } this._show = this.show.bind(this) this._hide = this.hide.bind(this) this._maintainFocus = this.maintainFocus.bind(this) this._handleKeyDown = this.handleKeyDown.bind(this) element.setAttribute('hidden', true) element.setAttribute('role', 'dialog') element.setAttribute('aria-modal', true) const closers = [...element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.addEventListener('click', this._hide)) } show() { this.isShown = true this.previouslyFocused = document.activeElement this.element.removeAttribute('hidden') this.moveFocusIn() document.addEventListener('keydown', this._handleKeyDown) document.body.addEventListener('focus', this._maintainFocus, true) this.events.show.forEach(event => event()) } hide() { if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } this.isShown = false this.element.setAttribute('hidden', true) document.removeEventListener('keydown', this._handleKeyDown) document.body.removeEventListener('focus', this._maintainFocus, true) this.events.hide.forEach(event => event()) } destroy() { const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.removeEventListener('click', this._hide)) this.events.show.forEach(event => this.off('show', event)) this.events.hide.forEach(event => this.off('hide', event)) } on(type, fn) { this.events[type].push(fn) } off(type, fn) { const index = this.events[type].indexOf(fn) if (index > -1) this.events[type].splice(index, 1) } handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) } moveFocusIn() { const target = this.element.querySelector('[autofocus]') || getFocusableChildren(this.element)[0] if (target) target.focus() } maintainFocus(event) { const isInDialog = event.target.closest('[aria-modal="true"]') if (!isInDialog) this.moveFocusIn() } } function trapTabKey(node, event) { const focusableChildren = getFocusableChildren(node) const focusedItemIndex = focusableChildren.indexOf(document.activeElement) const lastIndex = focusableChildren.length - 1 const withShift = event.shiftKey if (withShift && focusedItemIndex === 0) { focusableChildren[lastIndex].focus() event.preventDefault() } else if (!withShift && focusedItemIndex === lastIndex) { focusableChildren[0].focus() event.preventDefault() } } function isVisible(element) { return element => element.offsetWidth || element.offsetHeight || element.getClientRects().length } function getFocusableChildren(root) { const elements = [...root.querySelectorAll(focusableSelectors.join(','))] return elements.filter(isVisible) }
Membungkus
Itu cukup sesuatu, tapi kami akhirnya sampai di sana! Sekali lagi, saya menyarankan untuk tidak meluncurkan pustaka dialog Anda sendiri karena ini bukan yang paling mudah dan kesalahan bisa sangat bermasalah bagi pengguna teknologi bantu. Tapi setidaknya sekarang Anda tahu cara kerjanya!
Jika Anda perlu menggunakan dialog dalam proyek Anda, pertimbangkan untuk menggunakan salah satu solusi berikut (pengingat bahwa kami juga memiliki daftar lengkap komponen yang dapat diakses):
- Implementasi JavaScript Vanilla: a11y-dialog oleh Anda benar-benar atau aria-modal-dialog oleh Scott O'Hara.
- Implementasi reaksi: reaksi-a11y-dialog oleh Anda benar-benar lagi, jangkauan/dialog dari kerangka Jangkauan, atau @react-aria/dialog dari Adobe. Anda mungkin tertarik dengan perbandingan 3 perpustakaan ini.
- Implementasi Vue: vue-a11y-dialog oleh Moritz Kroger, a11y-vue-dialog oleh Renato de Leao.
Berikut adalah lebih banyak hal yang dapat ditambahkan tetapi tidak demi kesederhanaan:
- Dukungan untuk dialog peringatan melalui peran
alertdialog
peringatan. Lihat dokumentasi dialog a11y tentang dialog peringatan. - Mengunci kemampuan untuk menggulir saat dialog terbuka. Lihat dokumentasi dialog a11y tentang kunci gulir.
- Dukungan untuk elemen
<dialog>
HTML asli karena di bawah standar dan tidak konsisten. Lihat dokumentasi dialog a11y pada elemen dialog dan bagian ini oleh Scott O'hara untuk informasi lebih lanjut tentang mengapa itu tidak sepadan dengan masalahnya. - Dukungan untuk dialog bersarang karena dipertanyakan. Lihat dokumentasi dialog a11y tentang dialog bersarang.
- Pertimbangan untuk menutup dialog pada navigasi browser. Dalam beberapa kasus, mungkin masuk akal untuk menutup dialog saat menekan tombol kembali browser.