react-modal のアクセシビリティまわりの実装を読む

10 min read読了の目安(約9800字

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の propsappElement を渡すことでも同じことができそうですが、モーダルダイアログを作るたびに毎回 appElement を指定するのも、そもそも個別のコンポーネントがルート要素を知っているのもおかしいということで、Modal.setAppElement の使用が推奨されているのでは、と思っています)

setAppElementの実装はどうやら ariaAppHider というところにあるようです

https://github.com/reactjs/react-modal/blob/v3.11.2/src/components/Modal.js#L25-L27
  static setAppElement(element) {
    ariaAppHider.setElement(element);
  }

https://github.com/reactjs/react-modal/blob/v3.11.2/src/helpers/ariaAppHider.js#L4-L23

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.jsglobalElement に保持されているようです。

そしてここで保持した要素に対して aria-hidden を付加したり外したりする処理もそのすぐ下にあります

https://github.com/reactjs/react-modal/blob/v3.11.2/src/helpers/ariaAppHider.js#L44-L54
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.jsbeforeOpen() afterOpen() で呼ばれているようです。詳しく読んでないですが開く前に hide して閉じたあとに showしているということでしょう。

モーダルを開いた瞬間に画面が真っ白になってしまう問題について

話題は少し脱線しますが、この app element の機能で実際にトラブルが起きたことがあるため、紹介しておきます。

app element を指定しないでreact-modalを使用すると、consoleに警告が表示されます。

https://github.com/reactjs/react-modal/blob/v3.11.2/src/helpers/ariaAppHider.js#L25-L42
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が呼ばれ、ここで focusblur イベントのlistenerが追加されます。

https://github.com/reactjs/react-modal/blob/v3.11.2/src/helpers/focusManager.js#L61-L71
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() が呼ばれます。

https://github.com/reactjs/react-modal/blob/v3.11.2/src/helpers/focusManager.js#L32-L34
export function markForFocusLater() {
  focusLaterElements.push(document.activeElement);
}

現在フォーカスがある要素を focusLaterElements に格納しています。これもファイルの冒頭で、 const focusLaterElements = [] という配列を定義しています。

そして、 handleBlurhandleFocus を読むと、フォーカスがblurしたときにフラグを立て、そして別の要素にフォーカスしたタイミングでそのフラグが立っていて、かつモーダルの外にフォーカスしていたら(modalElement.contains(document.activeElement)false なら)、モーダル内にフォーカスを戻すようなことをしているようです。

https://github.com/reactjs/react-modal/blob/v3.11.2/src/helpers/focusManager.js#L7-L30
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);
  }
}

findTabbablehelpers/tabbable.js の default exportとして定義されていて、ファイル冒頭には Adapted from jQuery UI core と書かれていて、つまりjQuery UI由来のコードであることがわかります。中身はノードが inputselecta[href] で、しかも視覚的に見える要素かどうかを地道に確かめていくような感じです(そろそろソースコードを引用してくるのが面倒くさい!)

https://github.com/reactjs/react-modal/blob/v3.11.2/src/helpers/tabbable.js

フォーカスについてはこれで十分かと思いきや、ModalPortal.jsではさらに scopeTab というものも使われています。これはModalPortalの handleKeyDown にて呼び出されています。

https://github.com/reactjs/react-modal/blob/v3.11.2/src/components/ModalPortal.js#L260-L269
  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 に実装があります。

https://github.com/reactjs/react-modal/blob/v3.11.2/src/helpers/scopeTab.js#L12-L37
  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-labelaria-labelledbyaria-describedby のような属性を簡単に付けられるよという以上のものではなさそうです。

実装を見ると aria-data- で始まるものは何でも使えそうです。また、 rolecontentLabel という名前で aria-label を渡したりもできそうです。

https://github.com/reactjs/react-modal/blob/v3.11.2/src/components/ModalPortal.js#L336-L340
  attributesFromObject = (prefix, items) =>
    Object.keys(items).reduce((acc, name) => {
      acc[`${prefix}-${name}`] = items[name];
      return acc;
    }, {});

https://github.com/reactjs/react-modal/blob/v3.11.2/src/components/ModalPortal.js#L365-L368
          role={this.props.role}
          aria-label={this.props.contentLabel}
          {...this.attributesFromObject("aria", this.props.aria || {})}
          {...this.attributesFromObject("data", this.props.data || {})}

おそらく、rolerole="dialog"role="alertdialog" が適切だろうと思うのですが、自動的にこれらの値が入るようにはなっていないようです。

おわりに

もともと setAppElement(document.body) をやってしまった反省があったのと、フォーカスの制御をどうやっているのかに興味があって読みはじめたのですが、記事にするためにきちんと読むと、今回紹介しなかった以外にもいろいろと新たな発見をすることができました。

米国製のUIライブラリにはアクセシビリティを考慮した機能を持ったものがけっこうあるので、またコードのライブラリを読んでみたら今回のような記事を書こうかと思います