Sıfırdan Erişilebilir Bir İletişim Kutusu Oluşturma

Yayınlanan: 2022-03-10
Kısa özet ↬ Modern arayüz tasarımında (iyi ya da kötü) her yerde diyaloglar bulunur ve yine de bunların çoğu yardımcı teknolojiler tarafından erişilebilir değildir. Bu yazıda, erişilebilir diyaloglar oluşturmak için kısa bir komut dosyasının nasıl oluşturulacağını inceleyeceğiz.

Öncelikle bunu evde yapmayın. Bunu yapmak için kendi diyaloglarınızı veya bir kitaplığınızı yazmayın. Zaten test edilmiş, denetlenmiş, kullanılmış ve yeniden kullanılmış pek çok şey var ve bunları kendi başınıza tercih etmelisiniz. a11y-dialog bunlardan biri, ancak daha fazlası var (bu makalenin sonunda listelenmiştir).

Bu gönderiyi, diyalogları kullanırken hepinize dikkatli olmanızı hatırlatmak için bir fırsat olarak almama izin verin. Özellikle mobil cihazlarda, tüm tasarım sorunlarını bunlarla ele almak geçicidir, ancak genellikle tasarım sorunlarının üstesinden gelmenin başka yolları vardır. Diyalogları mutlaka doğru seçim oldukları için değil, kolay oldukları için kullanma eğilimindeyiz. Her zaman doğru bir değiş tokuş olmayan bağlam değiştirme için takas ederek ekran mülk sorunlarını bir kenara bırakırlar. Mesele şu ki: kullanmadan önce bir diyaloğun doğru tasarım modeli olup olmadığını düşünün.

Bu yazıda, en başından erişilebilir diyaloglar yazmak için küçük bir JavaScript kitaplığı yazacağız (esas olarak bir 11y-iletişimini yeniden yaratıyoruz). Amaç, içinde ne olduğunu anlamaktır. Stil ile çok fazla uğraşmayacağız, sadece JavaScript kısmıyla uğraşacağız. Basit olması için modern JavaScript'i kullanacağız (sınıflar ve ok işlevleri gibi), ancak bu kodun eski tarayıcılarda çalışmayabileceğini unutmayın.

  1. API'yi tanımlama
  2. Diyaloğu somutlaştırma
  3. Gösterme ve gizleme
  4. Kaplama ile kapatma
  5. kaçış ile kapanış
  6. Odak yakalama
  7. Odaklanmayı sürdürmek
  8. Odak geri yükleniyor
  9. Erişilebilir bir ad verme
  10. Özel olayları işleme
  11. Temizlemek
  12. hepsini bir araya getir
  13. toparlamak

API'yi Tanımlamak

İlk olarak, diyalog komut dosyamızı nasıl kullanacağımızı tanımlamak istiyoruz. Başlamak için mümkün olduğunca basit tutacağız. Ona diyalogumuz için kök HTML öğesini veriyoruz ve aldığımız örneğin bir .show(..) ve bir .hide(..) yöntemi var.

 class Dialog { constructor(element) {} show() {} hide() {} }

Diyaloğu Başlatmak

Diyelim ki aşağıdaki HTML'ye sahibiz:

 <div>This will be a dialog.</div>

Ve diyalogumuzu şu şekilde somutlaştırıyoruz:

 const element = document.querySelector('#my-dialog') const dialog = new Dialog(element)

Bunu başlatırken kaputun altında yapmamız gereken birkaç şey var:

  • Varsayılan olarak gizlenmesi için gizleyin ( hidden ).
  • Yardımcı teknolojiler için bir iletişim kutusu olarak işaretleyin ( role="dialog" ).
  • Açıldığında sayfanın geri kalanını eylemsiz hale getirin ( 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) }

Bu 3 özelliği JavaScript ile eklemek zorunda kalmamak için ilk HTML'mize ekleyebileceğimizi unutmayın, ancak bu şekilde gözden uzak, akıl dışı olur. Komut dosyamız, tüm niteliklerimizi eklemeyi düşünüp düşünmediğimize bakılmaksızın, her şeyin olması gerektiği gibi çalışmasını sağlayabilir.

Göstermek ve Gizlemek

İki yöntemimiz var: biri diyaloğu göstermek için, diğeri onu gizlemek için. Bu yöntemler, (şimdilik) kök öğedeki hidden özniteliği değiştirmekten başka bir işe yaramaz. Ayrıca, iletişim kutusunun gösterilip gösterilmediğini hızlı bir şekilde değerlendirebilmek için örnekte bir boole tutacağız. Bu daha sonra kullanışlı olacaktır.

 show() { this.isShown = true this.element.removeAttribute('hidden') } hide() { this.isShown = false this.element.setAttribute('hidden', true) }

JavaScript devreye girmeden ve özniteliği ekleyerek onu gizlemeden önce iletişim kutusunun görünür olmasını önlemek için, en başından itibaren doğrudan HTML'de iletişim kutusuna hidden eklemek ilginç olabilir.

 <div hidden>This will be a dialog.</div>

Bindirmeli Kapanış

İletişim kutusunun dışına tıklamak onu kapatmalıdır. Bunu yapmanın birkaç yolu vardır. Bunun bir yolu, sayfadaki tüm tıklama olaylarını dinlemek ve iletişim kutusunda meydana gelenleri filtrelemek olabilir, ancak bunu yapmak nispeten karmaşıktır.

Başka bir yaklaşım, bindirme üzerindeki tıklama olaylarını dinlemek olacaktır (bazen “zemin” olarak adlandırılır). Bindirmenin kendisi, bazı stiller ile bir <div> kadar basit olabilir.

Bu yüzden diyaloğu açarken, bindirme üzerindeki tıklama olaylarını bağlamamız gerekiyor. Sorgulayabilmesi için ona bir kimlik veya belirli bir sınıf verebiliriz veya ona bir veri niteliği verebiliriz. Bunları davranış kancaları için tercih etme eğilimindeyim. HTML'mizi buna göre değiştirelim:

 <div hidden> <div data-dialog-hide></div> <div>This will be a dialog.</div> </div>

Şimdi, diyalog içinde data-dialog-hide özniteliğine sahip elemanları sorgulayabilir ve onlara diyalogu gizleyen bir tıklama dinleyicisi verebiliriz.

 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)) }

Bunun gibi oldukça genel bir şeye sahip olmanın güzel yanı, aynı şeyi diyaloğun kapat düğmesi için de kullanabilmemizdir.

 <div hidden> <div data-dialog-hide></div> <div> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>
Atlamadan sonra daha fazlası! Aşağıdan okumaya devam edin ↓

Kaçış ile Kapanış

İletişim kutusu yalnızca dışına tıklandığında gizlenmemeli, aynı zamanda Esc tuşuna basıldığında da gizlenmelidir. Diyaloğu açarken, bir klavye dinleyicisini belgeye bağlayabilir ve kapatırken kaldırabiliriz. Bu şekilde, her zaman yerine yalnızca iletişim kutusu açıkken tuşa basışları dinler.

 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() }

Odak Yakalama

Şimdi bu iyi şeyler. Odağı diyalog içine hapsetmek, her şeyin özünde bir tür ve en karmaşık kısım olmalı (muhtemelen düşündüğünüz kadar karmaşık olmasa da).

Fikir oldukça basit: iletişim kutusu açıkken Tab basışlarını dinliyoruz. İletişim kutusunun odaklanılabilir son öğesinde Tab'a basarsanız, odağı programlı olarak ilk öğeye taşırız. Diyaloğun ilk odaklanılabilir öğesinde Shift + Tab tuşlarına basarsanız, onu sonuncuya taşırız.

İşlev şöyle görünebilir:

 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() } }

Bulmamız gereken bir sonraki şey, iletişim kutusunun tüm odaklanabilir öğelerini nasıl elde edeceğimizdir ( getFocusableChildren ). Teorik olarak odaklanılabilir olabilecek tüm öğeleri sorgulamamız ve ardından etkili bir şekilde olduklarından emin olmamız gerekiyor.

İlk kısım odaklanabilir seçicilerle yapılabilir. Bu seçiciler dizisini sağlayan, yazdığım ufacık bir paket:

 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^="-"])', ]

Ve bu sizi %99 oraya götürmek için yeterli. Tüm odaklanılabilir öğeleri bulmak için bu seçicileri kullanabiliriz ve ardından ekranda gerçekten görünür olduğundan (ve gizli veya başka bir şey olmadığından) emin olmak için her birini kontrol edebiliriz.

 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) }

Artık handleKeyDown yöntemimizi güncelleyebiliriz:

 handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) }

Odaklanmayı Sürdürmek

Erişilebilir diyaloglar oluştururken genellikle gözden kaçan bir şey, sayfa odağı kaybettikten sonra bile odağın diyalog içinde kalmasını sağlamaktır. Bunu şu şekilde düşünün: diyalog bir kez açıldığında ne olur? Tarayıcının URL çubuğuna odaklanıyoruz ve ardından tekrar sekmeye başlıyoruz. Odak tuzağımız işe yaramayacak, çünkü yalnızca diyalog içindeyken odağı koruyor.

Bu sorunu çözmek için, iletişim kutusu gösterildiğinde bir odak dinleyicisini <body> öğesine bağlayabilir ve odağı, iletişim kutusundaki ilk odaklanabilir öğeye taşıyabiliriz.

 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() }

İletişim kutusunu açarken hangi öğeye odaklanılacağı zorunlu değildir ve bu, iletişim kutusunun görüntülediği içerik türüne bağlı olabilir. Genel olarak konuşursak, birkaç seçenek vardır:

  • İlk öğeye odaklanın.
    Burada yaptığımız şey bu, çünkü zaten bir getFocusableChildren işlevine sahip olmamız bunu kolaylaştırıyor.
  • Kapat düğmesine odaklanın.
    Bu, özellikle düğme kesinlikle diyaloğa göre konumlandırılmışsa, iyi bir çözümdür. Kapat butonumuzu diyalogumuzun ilk öğesi olarak yerleştirerek bunu rahatlıkla gerçekleştirebiliriz. Kapat düğmesi iletişim kutusunun içeriğinin akışında en sonda bulunuyorsa, iletişim kutusunun çok fazla içeriği varsa (ve dolayısıyla kaydırılabilirse) açıkken içeriği sonuna kadar kaydıracağı için sorun olabilir.
  • Diyaloğun kendisine odaklanın .
    Bu, iletişim kitaplıkları arasında çok yaygın değildir, ancak aynı zamanda çalışması gerekir (ancak buna tabindex="-1" eklenmesini gerektirse de, bu mümkündür, çünkü bir <div> öğesi varsayılan olarak odaklanabilir değildir).

İletişim kutusunda autofocus HTML özniteliğine sahip bir öğe olup olmadığını kontrol ettiğimizi unutmayın; bu durumda odağı ilk öğe yerine ona taşırız.

Odaklanmayı Geri Yükleme

Odağı diyalog içinde başarılı bir şekilde yakalamayı başardık, ancak diyalog açıldıktan sonra odağı diyalog içine taşımayı unuttuk. Benzer şekilde, odağı, diyalog açılmadan önce sahip olduğu öğeye geri döndürmemiz gerekiyor.

İletişim kutusunu gösterirken, odağı olan öğeye ( document.activeElement ) bir referans tutarak başlayabiliriz. Çoğu zaman bu, iletişim kutusunu açmak için etkileşimde bulunulan düğme olacaktır, ancak bir iletişim kutusunun programlı olarak açıldığı nadir durumlarda, başka bir şey olabilir.

 show() { this.previouslyFocused = document.activeElement // … rest of the code this.moveFocusIn() }

İletişim kutusunu gizlerken odağı tekrar o öğeye taşıyabiliriz. Öğe bir şekilde artık mevcut değilse (veya bir SVG ise) JavaScript hatasını önlemek için bir koşulla koruyoruz:

 hide() { // … rest of the code if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } }

Erişilebilir Bir Ad Verme

İletişim kutumuzun erişilebilir bir ada sahip olması önemlidir, bu nedenle erişilebilirlik ağacında listelenecektir. Bunu ele almanın birkaç yolu vardır, bunlardan biri aria-label özniteliğinde bir ad tanımlamaktır, ancak aria-label sorunları vardır.

Başka bir yol, diyalogumuzda (gizli olsun veya olmasın) bir başlığa sahip olmak ve diyalogumuzu aria-labelledby niteliği ile ilişkilendirmektir. Şöyle görünebilir:

 <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>

Sanırım betiğimizin bu özelliği, başlığın varlığına bağlı olarak dinamik olarak uygulamasını sağlayabiliriz, ancak bunun başlangıçta uygun HTML yazılarak kolayca çözülebileceğini söyleyebilirim. Bunun için JavaScript eklemeye gerek yok.

Özel Olayları İşleme

Açık olan diyaloğa tepki vermek istersek ne olur? Yoksa kapalı mı? Şu anda bunu yapmanın bir yolu yok, ancak küçük bir etkinlik sistemi eklemek çok zor olmamalı. Olayları kaydetmek için bir fonksiyona (buna .on( .on(..) ) diyelim) ve onların kaydını silmek için bir fonksiyona ( .off(..) ) ihtiyacımız var.

 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) } }

Ardından, yöntemi gösterip gizlerken, o olay için kaydedilmiş tüm işlevleri çağıracağız.

 class Dialog { show() { // … rest of the code this.events.show.forEach(event => event()) } hide() { // … rest of the code this.events.hide.forEach(event => event()) } }

Temizlemek

Bir iletişim kutusunu kullanmayı bitirmemiz durumunda onu temizlemek için bir yöntem sağlamak isteyebiliriz. Olay dinleyicilerinin kaydının silinmesinden sorumlu olur, böylece olması gerekenden daha uzun sürmezler.

 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)) } }

Hepsini Bir Araya Getirmek

 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) }

Toplama

Bu oldukça önemliydi, ama sonunda oraya vardık! Bir kez daha, kendi diyalog kitaplığınızı kullanıma sunmamanızı tavsiye ederim, çünkü bu çok basit değil ve hatalar yardımcı teknoloji kullanıcıları için oldukça sorunlu olabilir. Ama en azından artık kaputun altında nasıl çalıştığını biliyorsunuz!

Projenizde iletişim kutuları kullanmanız gerekiyorsa, aşağıdaki çözümlerden birini kullanmayı düşünün (kapsamlı erişilebilir bileşenler listemize de sahip olduğumuzu hatırlatmak isteriz):

  • Vanilla JavaScript uygulamaları: a11y-dialog sizinki veya Scott O'Hara'nın aria-modal-dialog'u.
  • React uygulamaları: React-a11y-dialog sizinkiyle gerçekten yeniden, Reach çerçevesinden erişim/dialog veya Adobe'den @react-aria/dialog. 3 kütüphanenin bu karşılaştırması ilginizi çekebilir.
  • Vue uygulamaları: Moritz Kroger'den vue-a11y-dialog, Renato de Leao'dan a11y-vue-dialog.

İşte eklenebilecek, ancak basitlik uğruna olmayan daha fazla şey:

  • alertdialog rolü aracılığıyla uyarı diyalogları için destek. Uyarı iletişim kutularındaki a11y iletişim kutusu belgelerine bakın.
  • İletişim kutusu açıkken kaydırma yeteneğinin kilitlenmesi. Kaydırma kilidiyle ilgili a11y iletişim kutusu belgelerine bakın.
  • Alt düzeyde ve tutarsız olduğundan yerel HTML <dialog> öğesi için destek. Neden zahmete değmediği hakkında daha fazla bilgi için diyalog öğesindeki a11y-diyalog belgelerine ve Scott O'hara'nın bu parçasına bakın.
  • Şüpheli olduğu için iç içe diyaloglar için destek. İç içe iletişim kutuları hakkında a11y iletişim kutusu belgelerine bakın.
  • Tarayıcı navigasyonunda iletişim kutusunu kapatmaya yönelik değerlendirme. Bazı durumlarda, tarayıcının geri düğmesine basarken iletişim kutusunu kapatmak mantıklı olabilir.