アクセシブルなダイアログを最初から作成する
公開: 2022-03-10まず第一に、家でこれをしないでください。 独自のダイアログやライブラリを作成しないでください。 テスト、監査、使用、再利用されたものはすでにたくさんあります。自分のものよりもこれらを優先する必要があります。 a11y-dialogはその1つですが、他にもあります(この記事の最後にリストされています)。
この投稿を機会として、ダイアログを使用する際には注意が必要であることを皆さんに思い出させてください。 特にモバイルでは、すべての設計上の問題に対処することは魅力的ですが、設計上の問題を克服する方法は他にもあることがよくあります。 ダイアログが必ずしも正しい選択であるという理由ではなく、簡単であるという理由で、私たちはすぐにダイアログの使用に陥る傾向があります。 彼らは、必ずしも正しいトレードオフではないコンテキスト切り替えのためにそれらを交換することによって、画面の資産の問題を脇に置きます。 重要なのは、ダイアログを使用する前に、ダイアログが正しいデザインパターンであるかどうかを検討することです。
この投稿では、アクセス可能なダイアログを最初から作成するための小さなJavaScriptライブラリを作成します(基本的にはa11y-dialogを再作成します)。 目標は、それに何が入るのかを理解することです。 スタイリングについてはあまり扱いません。JavaScriptの部分だけを扱います。 簡単にするために最新のJavaScript(クラスや矢印関数など)を使用しますが、このコードはレガシーブラウザーでは機能しない可能性があることに注意してください。
- APIの定義
- ダイアログのインスタンス化
- 表示と非表示
- オーバーレイで閉じる
- エスケープで閉じる
- トラッピングフォーカス
- 焦点を維持する
- フォーカスの復元
- アクセシブルな名前を付ける
- カスタムイベントの処理
- 清掃
- すべてをまとめる
- まとめ
APIの定義
まず、ダイアログスクリプトをどのように使用するかを定義します。 そもそも可能な限りシンプルに保つつもりです。 ダイアログのルートHTML要素を指定し、取得したインスタンスには.show(..)メソッドと.show(..)
.hide(..)
メソッドがあります。
class Dialog { constructor(element) {} show() {} hide() {} }
ダイアログのインスタンス化
次のHTMLがあるとしましょう。
<div>This will be a dialog.</div>
そして、次のようにダイアログをインスタンス化します。
const element = document.querySelector('#my-dialog') const dialog = new Dialog(element)
インスタンス化するときに、内部で実行する必要があることがいくつかあります。
- デフォルトで非表示になるように非表示にします(
hidden
)。 - 支援技術のダイアログとしてマークします(
role="dialog"
)。 - 開いたときにページの残りの部分を不活性にします(
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) }
これらの3つの属性をJavaScriptで追加する必要がないように、最初のHTMLに追加することもできますが、この方法では、これらの3つの属性は見えなくなります。 私たちのスクリプトは、すべての属性を追加することを考えているかどうかに関係なく、物事が正常に機能することを確認できます。
表示と非表示
ダイアログを表示する方法と非表示にする方法の2つの方法があります。 これらのメソッドは、ルート要素のhidden
属性を切り替える以外は、(今のところ)あまり機能しません。 また、ダイアログが表示されているかどうかをすばやく評価できるように、インスタンスにブール値を維持します。 これは後で便利になります。
show() { this.isShown = true this.element.removeAttribute('hidden') } hide() { this.isShown = false this.element.setAttribute('hidden', true) }
JavaScriptが起動して属性を追加することでダイアログを非表示にする前にダイアログが表示されないようにするには、最初からHTMLで直接ダイアログにhidden
を追加するのが面白いかもしれません。
<div hidden>This will be a dialog.</div>
オーバーレイで閉じる
ダイアログの外側をクリックすると、ダイアログが閉じます。 これを行うにはいくつかの方法があります。 1つの方法は、ページ上のすべてのクリックイベントをリッスンし、ダイアログ内で発生するイベントを除外することですが、それを行うのは比較的複雑です。
別のアプローチは、オーバーレイ(「背景」と呼ばれることもあります)のクリックイベントをリッスンすることです。 オーバーレイ自体は、いくつかのスタイルで<div>
のように単純にすることができます。
したがって、ダイアログを開くときに、オーバーレイのクリックイベントをバインドする必要があります。 IDまたは特定のクラスを指定してクエリを実行したり、データ属性を指定したりできます。 私はビヘイビアフックのためにこれらを好む傾向があります。 それに応じてHTMLを変更してみましょう。
<div hidden> <div data-dialog-hide></div> <div>This will be a dialog.</div> </div>
これで、ダイアログ内のdata-dialog-hide
属性を使用して要素をクエリし、ダイアログを非表示にするクリックリスナーを要素に与えることができます。
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)) }
このような非常に一般的なものを使用することの良い点は、ダイアログの閉じるボタンにも同じものを使用できることです。
<div hidden> <div data-dialog-hide></div> <div> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>
エスケープで閉じる
ダイアログの外側をクリックするとダイアログが非表示になるだけでなく、 Escキーを押すとダイアログも非表示になります。 ダイアログを開くときに、キーボードリスナーをドキュメントにバインドし、閉じるときに削除することができます。 このように、ダイアログが開いている間は常にではなく、キーの押下のみをリッスンします。
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() }
トラッピングフォーカス
さて、それは良いことです。 ダイアログ内にフォーカスをトラップすることは、本質的には一種であり、最も複雑な部分である必要があります(ただし、おそらくあなたが思うほど複雑ではありません)。
考え方は非常に単純です。ダイアログが開いているとき、タブの押下をリッスンします。 ダイアログの最後のフォーカス可能な要素でTabキーを押すと、プログラムでフォーカスが最初の要素に移動します。 ダイアログの最初のフォーカス可能な要素でShift + Tabを押すと、最後の要素に移動します。
関数は次のようになります。
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() } }
次に理解する必要があるのは、ダイアログのすべてのフォーカス可能な要素( getFocusableChildren
)を取得する方法です。 理論的に焦点を合わせることができるすべての要素をクエリする必要があります。次に、それらが効果的に焦点を合わせていることを確認する必要があります。
最初の部分は、フォーカス可能なセレクターを使用して実行できます。 これは私が書いた小さなパッケージで、この一連のセレクターを提供します。
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^="-"])', ]
そして、これはあなたを99%そこに連れて行くのに十分です。 これらのセレクターを使用して、フォーカス可能なすべての要素を検索し、それらすべてをチェックして、実際に画面に表示されている(非表示などではない)ことを確認できます。
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) }
これで、 handleKeyDown
メソッドを更新できます。
handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) }
フォーカスの維持
アクセシブルなダイアログを作成するときに見落とされがちなことの1つは、ページのフォーカスが失われた後でも、フォーカスがダイアログ内にとどまるようにすることです。 このように考えてください。ダイアログが開いたらどうなりますか? ブラウザのURLバーにフォーカスを合わせてから、タブを再度開始します。 フォーカストラップは、最初からダイアログ内にある場合にのみダイアログ内のフォーカスを保持するため、機能しません。
この問題を修正するには、ダイアログが表示されたときにフォーカスリスナーを<body>
要素にバインドし、ダイアログ内の最初のフォーカス可能な要素にフォーカスを移動します。
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() }
ダイアログを開くときにフォーカスする要素は強制されません。また、ダイアログに表示されるコンテンツのタイプによって異なる場合があります。 一般的に、いくつかのオプションがあります。
- 最初の要素に焦点を合わせます。
これは、getFocusableChildren
関数がすでにあるため、ここで行うことです。 - 閉じるボタンにフォーカスします。
これは、特にボタンがダイアログに対して絶対的に配置されている場合にも、優れたソリューションです。 ダイアログの最初の要素として閉じるボタンを配置することで、これを簡単に実現できます。 [閉じる]ボタンがダイアログコンテンツのフロー内にある場合、最後に、ダイアログに多くのコンテンツが含まれている(したがってスクロール可能である)場合、開いたときにコンテンツが最後までスクロールされるため、問題が発生する可能性があります。 - ダイアログ自体にフォーカスします。
これはダイアログライブラリ間ではあまり一般的ではありませんが、機能するはずです(ただし、デフォルトでは<div>
要素はフォーカスできないため、tabindex="-1"
を追加する必要があります)。
ダイアログ内にautofocus
HTML属性を持つ要素があるかどうかを確認することに注意してください。その場合、最初の項目ではなく、その要素にフォーカスを移動します。
フォーカスの復元
ダイアログ内でフォーカスをトラップすることに成功しましたが、ダイアログが開いたらフォーカスをダイアログ内に移動するのを忘れました。 同様に、ダイアログを開く前にフォーカスを持っていた要素にフォーカスを戻す必要があります。
ダイアログを表示するとき、フォーカスを持つ要素( document.activeElement
)への参照を保持することから始めることができます。 ほとんどの場合、これはダイアログを開くために操作されたボタンですが、まれに、ダイアログがプログラムで開かれる場合は、別のボタンである可能性があります。
show() { this.previouslyFocused = document.activeElement // … rest of the code this.moveFocusIn() }
ダイアログを非表示にすると、フォーカスをその要素に戻すことができます。 要素が何らかの形で存在しなくなった場合(またはSVGであった場合)にJavaScriptエラーを回避するための条件で保護します。
hide() { // … rest of the code if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } }
アクセシブルな名前を付ける
ダイアログにアクセシブルな名前を付けることが重要です。これは、アクセシビリティツリーに表示される方法です。 これに対処する方法はいくつかあります。そのうちの1つは、 aria-label
属性で名前を定義することですが、 aria-label
には問題があります。
もう1つの方法は、ダイアログ内にタイトルを設定し(非表示かどうかに関係なく)、ダイアログをaria-labelledby
属性に関連付けることです。 次のようになります。
<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>
タイトルの有無などに基づいて、スクリプトにこの属性を動的に適用させることもできると思いますが、そもそも適切なHTMLを作成することで、これは同じように簡単に解決できると思います。 そのためにJavaScriptを追加する必要はありません。
カスタムイベントの処理
ダイアログが開いていることに反応したい場合はどうなりますか? または閉じていますか? 現在、それを行う方法はありませんが、小さなイベントシステムを追加することはそれほど難しいことではありません。 イベントを登録する関数( .on(..)
と呼びましょう)と、イベントの登録を解除する関数( .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) } }
次に、メソッドを表示および非表示にするときに、その特定のイベントに登録されているすべての関数を呼び出します。
class Dialog { show() { // … rest of the code this.events.show.forEach(event => event()) } hide() { // … rest of the code this.events.hide.forEach(event => event()) } }
清掃
ダイアログを使い終わった場合に備えて、ダイアログをクリーンアップする方法を提供したい場合があります。 イベントリスナーの登録を解除して、必要以上に持続しないようにする責任があります。
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)) } }
すべてをまとめる
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) }
まとめ
それはかなりのことでしたが、最終的にはそこに着きました! 繰り返しになりますが、独自のダイアログライブラリを展開することはお勧めしません。これは、最も単純なものではなく、支援技術のユーザーにとってエラーが非常に問題になる可能性があるためです。 しかし、少なくとも今、あなたはそれが内部でどのように機能するかを知っています!
プロジェクトでダイアログを使用する必要がある場合は、次のいずれかのソリューションの使用を検討してください(アクセス可能なコンポーネントの包括的なリストも用意されていることを忘れないでください)。
- Vanilla JavaScriptの実装:本当にあなたのものによるa11y-dialogまたはScottO'Haraによるaria-modal-dialog。
- Reactの実装:本当にもう一度react-a11y-dialog、Reachフレームワークからreach / dialog、またはAdobeから@ react-aria/dialog。 この3つのライブラリの比較に興味があるかもしれません。
- Vueの実装:Moritz Krogerによるvue-a11y-dialog、RenatodeLeaoによるa11y-vue-dialog。
追加できるものは他にもありますが、簡単にするためではありません。
-
alertdialog
ロールを介したalert-dialogのサポート。 アラートダイアログのa11y-dialogドキュメントを参照してください。 - ダイアログが開いているときにスクロールする機能をロックします。 スクロールロックに関するa11y-dialogのドキュメントを参照してください。
- ネイティブHTMLの
<dialog>
要素は、標準以下で一貫性がないため、サポートされています。 トラブルの価値がない理由の詳細については、ダイアログ要素のa11y-dialogドキュメントとScottO'haraによるこの記事を参照してください。 - 疑わしいため、ネストされたダイアログのサポート。 ネストされたダイアログに関するa11y-dialogのドキュメントを参照してください。
- ブラウザナビゲーションのダイアログを閉じる際の考慮事項。 場合によっては、ブラウザの戻るボタンを押したときにダイアログを閉じることが理にかなっていることがあります。