react-modal のアクセシビリティまわりの実装を読む
react-modalのアクセシビリティまわりの実装について、ソースコードを読んでみたかったのでその記録を残します。
特にことわりがない限り、 v3.11.2 の内容に基きます。
ドキュメント
react-modalのドキュメントには accessibility という項目があり、3つの機能について説明があります。
- The app element
- Keyboard Navigation
- ARIA attributes
The app element
主にスクリーンリーダーのユーザー向けの機能で、 aria-hidden
属性によって、モーダルが開いているときにページコンテンツを非表示と同じような状態にする機能です。視覚的にモーダルウインドウがページコンテンツの上に被さるように表示されている=ページコンテンツが隠されているのを、スクリーンリーダー向けにはモーダル以外の部分に aria-hidden
属性をつけることで実現しているようです。
これを使うには、事前に Modal.setAppElement('#root')
または Modal.setAppElement(document.getElementById('root'))
のように、アプリケーションのルートとなるelementを指定してやる必要があります。
(ドキュメントでは触れられていませんが、react-modalの props
に appElement
を渡すことでも同じことができそうですが、モーダルダイアログを作るたびに毎回 appElement
を指定するのも、そもそも個別のコンポーネントがルート要素を知っているのもおかしいということで、Modal.setAppElement
の使用が推奨されているのでは、と思っています)
setAppElement
の実装はどうやら ariaAppHider
というところにあるようです
static setAppElement(element) {
ariaAppHider.setElement(element);
}
let globalElement = null;
/* 中略 */
export function setElement(element) {
let useElement = element;
if (typeof useElement === "string" && canUseDOM) {
const el = document.querySelectorAll(useElement);
assertNodeList(el, useElement);
useElement = "length" in el ? el[0] : el;
}
globalElement = useElement || globalElement;
return globalElement;
}
ということで、setAppElement
に渡された要素は ariaAppHider.js
の globalElement
に保持されているようです。
そしてここで保持した要素に対して aria-hidden
を付加したり外したりする処理もそのすぐ下にあります
export function hide(appElement) {
if (validateElement(appElement)) {
(appElement || globalElement).setAttribute("aria-hidden", "true");
}
}
export function show(appElement) {
if (validateElement(appElement)) {
(appElement || globalElement).removeAttribute("aria-hidden");
}
}
これらの処理は、ModalPortal.js の beforeOpen()
afterOpen()
で呼ばれているようです。詳しく読んでないですが開く前に hide
して閉じたあとに show
しているということでしょう。
モーダルを開いた瞬間に画面が真っ白になってしまう問題について
話題は少し脱線しますが、この app element の機能で実際にトラブルが起きたことがあるため、紹介しておきます。
app element を指定しないでreact-modalを使用すると、consoleに警告が表示されます。
export function validateElement(appElement) {
if (!appElement && !globalElement) {
warning(
false,
[
"react-modal: App element is not defined.",
"Please use `Modal.setAppElement(el)` or set `appElement={el}`.",
"This is needed so screen readers don't see main content",
"when modal is opened. It is not recommended, but you can opt-out",
"by setting `ariaHideApp={false}`."
].join(" ")
);
return false;
}
return true;
}
そして、この警告を黙らせたいばかりに、ここに document.body
を入れてしまう、ということが起きてしまいがちなようです。たしかにアプリケーションのルート要素っぽいですし、ヘッダーやフッター部分がサーバーサイドでレンダリングされていたりして、アプリケーションのルート要素なんて無いよ!という場合もありそうです。
ここに document.body
を入れてしまうと、body
要素に aria-hidden="true"
がつけられてしまいます。 しかしモーダルダイアログの部分もページの一部ですから、bodyの直下に追加されます。そしてスクリーンリーダーは aria-hidden="true"
のついた要素をすべて無視します。つまり、モーダルを開いた瞬間に画面上のすべての要素が無視され、スクリーンリーダーからは真っ白なページに見えてしまうのです。
恥ずかしながら私の職場でも起きていましたし、他社さんのサービスでも起きていました(これは問い合わせたところすぐに修正いただけました。ありがとうございます)。
先述の「プリケーションのルート要素なんて無いよ!という場合」ですが、職場でスクリーンリーダーを使用する当事者も交えて相談したのですが、react-modalにはこのあと紹介するフォーカス制御も入っているので、モーダル以外の部分をaria-hidden="true"
にしなくても問題なく使えるのでは、という結論に達しました(It is not recommendedと言われてはいますが……)。その場合は ariaHideApp="false"
をreact-modalのpropsとして渡すことで、警告も出なくなるようです。
なお、react-modalのバージョンが3.1.5以前の場合には、Modal.setAppElement()
をしない場合に <body>
に aria-hidden="true"
が付与されてしまうのがデフォルトの挙動だったようです。もし古いバージョンを使用しているのであれば、いますぐアップデートするべきです。
Keyboard Navigation
モーダルが開いているあいだ、キーボードによる操作がモーダル内でしか行なわれないよう、モーダルの外の要素がフォーカスを受け取らないようにします。また、モーダルが閉じられたときにフォーカスの位置を元の位置に戻したり、Escキーでモーダル自体を閉じたりする機能を提供しています。
モーダルを開くときには、ModalPortal.jsの open()
から、まず helpers/focusManager.js
の setupScopedFocusが呼ばれ、ここで focus
と blur
イベントのlistenerが追加されます。
export function setupScopedFocus(element) {
modalElement = element;
if (window.addEventListener) {
window.addEventListener("blur", handleBlur, false);
document.addEventListener("focus", handleFocus, true);
} else {
window.attachEvent("onBlur", handleBlur);
document.attachEvent("onFocus", handleFocus);
}
}
modalElement
はこのファイルの冒頭で let modalElement = null
宣言がされています。blurはwindowに、focusはdocumentに対して addEventListner
(attachEvent
) しているのが面白いですね。
ここで追加されたイベントリスナーはteardownScopedFocus()
で解除されています
そして次に、markForFocusLater()
が呼ばれます。
export function markForFocusLater() {
focusLaterElements.push(document.activeElement);
}
現在フォーカスがある要素を focusLaterElements
に格納しています。これもファイルの冒頭で、 const focusLaterElements = []
という配列を定義しています。
そして、 handleBlur
と handleFocus
を読むと、フォーカスがblurしたときにフラグを立て、そして別の要素にフォーカスしたタイミングでそのフラグが立っていて、かつモーダルの外にフォーカスしていたら(modalElement.contains(document.activeElement)
が false
なら)、モーダル内にフォーカスを戻すようなことをしているようです。
export function handleBlur() {
needToFocus = true;
}
export function handleFocus() {
if (needToFocus) {
needToFocus = false;
if (!modalElement) {
return;
}
// need to see how jQuery shims document.on('focusin') so we don't need the
// setTimeout, firefox doesn't support focusin, if it did, we could focus
// the element outside of a setTimeout. Side-effect of this implementation
// is that the document.body gets focus, and then we focus our element right
// after, seems fine.
setTimeout(() => {
if (modalElement.contains(document.activeElement)) {
return;
}
const el = findTabbable(modalElement)[0] || modalElement;
el.focus();
}, 0);
}
}
findTabbable
は helpers/tabbable.js
の default exportとして定義されていて、ファイル冒頭には Adapted from jQuery UI core
と書かれていて、つまりjQuery UI由来のコードであることがわかります。中身はノードが input
や select
や a[href]
で、しかも視覚的に見える要素かどうかを地道に確かめていくような感じです(そろそろソースコードを引用してくるのが面倒くさい!)
フォーカスについてはこれで十分かと思いきや、ModalPortal.jsではさらに scopeTab
というものも使われています。これはModalPortalの handleKeyDown
にて呼び出されています。
handleKeyDown = event => {
if (event.keyCode === TAB_KEY) {
scopeTab(this.content, event);
}
if (this.props.shouldCloseOnEsc && event.keyCode === ESC_KEY) {
event.stopPropagation();
this.requestClose(event);
}
};
後半はEscキーで閉じる処理ですね(stopPropagation()
は何のためだろう……)。前半でタブキー押下時に呼ばれている scopeTab()
は、 helpers/scopeTab.js
に実装があります。
let target;
const shiftKey = event.shiftKey;
const head = tabbable[0];
const tail = tabbable[tabbable.length - 1];
// proceed with default browser behavior on tab.
// Focus on last element on shift + tab.
if (node === document.activeElement) {
if (!shiftKey) return;
target = tail;
}
if (tail === document.activeElement && !shiftKey) {
target = head;
}
if (head === document.activeElement && shiftKey) {
target = tail;
}
if (target) {
event.preventDefault();
target.focus();
return;
}
Tabキー押下時に、モーダル内でフォーカス可能な要素のうち最後の要素にフォーカスがある状態であればそれ以上先には行かないように、Shiftキーを押しながらのTabキー押下であれば先頭の要素よりも前に行かないようにしているようです。おそらく通常のユースケースでのフォーカスの制御はこちらの処理で十分で、ブラウザのURLバーにフォーカスを移してからTabキーを押した場合などのイレギュラーなケースではさきほどの handleBlur
handleFocus
が使われるのだと思います。
自分がこういう処理を書く場合には、前後にダミーのフォーカス可能要素を(視覚的には見えないかたちで)置いたりしていたので、そういった不要な要素を置かずにフォーカスの制御をしているのは凄いなという感想をもちました。
ARIA attributes
これについては、aria-label
や aria-labelledby
や aria-describedby
のような属性を簡単に付けられるよという以上のものではなさそうです。
実装を見ると aria-
と data-
で始まるものは何でも使えそうです。また、 role
や contentLabel
という名前で aria-label
を渡したりもできそうです。
attributesFromObject = (prefix, items) =>
Object.keys(items).reduce((acc, name) => {
acc[`${prefix}-${name}`] = items[name];
return acc;
}, {});
role={this.props.role}
aria-label={this.props.contentLabel}
{...this.attributesFromObject("aria", this.props.aria || {})}
{...this.attributesFromObject("data", this.props.data || {})}
おそらく、role
は role="dialog"
か role="alertdialog"
が適切だろうと思うのですが、自動的にこれらの値が入るようにはなっていないようです。
おわりに
もともと setAppElement(document.body)
をやってしまった反省があったのと、フォーカスの制御をどうやっているのかに興味があって読みはじめたのですが、記事にするためにきちんと読むと、今回紹介しなかった以外にもいろいろと新たな発見をすることができました。
米国製のUIライブラリにはアクセシビリティを考慮した機能を持ったものがけっこうあるので、またコードのライブラリを読んでみたら今回のような記事を書こうかと思います
Discussion