アクセシビリティに考慮したモーダルを作るというか写経した。

2020-03-15

僕もフォローさせていただいてるICSの池田さんが以下のようなツイートをしていた。

なるほど!自分が前職の受託時代にjQueryで、結構モーダルを作っていて純粋なJavaScriptで実装したことないし(Vueでは作ったことある)アクセシビリティを考慮したモーダルも要件としてなかったことが多く、あまり作ったことがないなーと思ったので作ってみた。

アクセシビリティに考慮??

ツイートにもあるが、モーダルが開いている時に純粋に実装するとモーダル展開時は、フォーカスがモーダル内に当たらずにページに残ってしまう。

これは俗に言うフォーカストラップというやつである。このブログを書く前にそういえばGoogleのブログ記事に以下があるのを思い出した。

tabindex の使用

https://developers.google.com/web/fundamentals/accessibility/focus/using-tabindex?hl=ja

ページ内でフォーカス管理するためのtipsが書かれているのだが、記事の下にフォーカストラップについて触れられていてモーダルのサンプルが出ている。「モーダルこれでいいじゃん。。」と思ったので、このサンプルコードを写経しつつ修正を加えてみた。

作成したものは以下の、GitHubに置いた。

https://github.com/ryokatsuse/blog_demo/tree/master/modal

ざっくり仕様

  • 「ログインモーダル」のボタンを押すとモーダルがフェードで表示させる
  • この時オーバーレイも表示される
  • モーダルが開いた状態でTABキーを押すと最初にIDのinputタグにフォーカスが当たる。
  • TABキーで次のフォーカス先、Shift+TABキーで前のフォーカス先に移動する。(モーダル内)
  • Xボタンクリックまたはモーダル展開時にescキーを押すとモーダルは閉じる。

変更点

HTML

アクセシビリティ対応を入れた。role属性とaria-labelledby属性、aria-describedby属性を付与して該当の箇所にidを指定した。

<div class="modal" role="dialog" aria-labelledby="dialog-header" aria-describedby="dialog-desc">
  <h1 id="dialog-header">ログイン</h1>
  <p id="dialog-desc">ログインしますか?</p>
  <div class="field">
    <label for="user_id">ID</label>
    <input id="user_id" type="text">
  </div>
  <div class="field">
    <label for="user_password">password</label>
    <input id="user_password" type="password">
  </div>
  <button id="signup">sign up</button>
  <button class="close">X</button>
</div>

role属性はdialogが推奨されており、ria-labelledbyとaria-describedby属性を使うことでスクリーンリーダーに対応することができます。(MDN) ARIA: dialog ロール

CSS

Googleのサンプルではモーダルの表示非表示にアニメーションがなかったのでフェードアニメーションを追加した。

JavaScript

まず変数が、var指定だったのでlet constにそれぞれ変更して関数もアロー関数に変更した。

openModal関数内では、以下のような処理をしている。汚いコードですみません。

const openModal = () => {
  //フォーカス中のDOMを保存する
  focusedElementBeforeModal = document.activeElement

  const focusableElementsString = 'a[href], area[href], input:not([disabled]), button:not([disabled]), object, embed, [tabindex="0"], [contenteditable]'
  let focusableElements = modal.querySelectorAll(focusableElementsString)

  focusableElements = [].slice.call(focusableElements)

  let firstTabStop = focusableElements[0]
  let lastTabStop = focusableElements[focusableElements.length -1]

  modal.classList.add('is-modal')
  modalOverlay.classList.add('is-modal-overlay')

  firstTabStop.focus()

  const trapTabKey = (e) => {
    if (e.keyCode === 9) {

      if(e.shiftKey) {
        if(document.activeElement === firstTabStop) {
          e.preventDefault()
          lastTabStop.focus()
        }
      } else {
        if (document.activeElement === lastTabStop) {
          e.preventDefault();
          firstTabStop.focus();
        }
      }
    }

    if (e.keyCode === 27) {
      closeModal();
    }
  }

  modal.addEventListener('keydown', trapTabKey)
  modalOverlay.addEventListener('click', closeModal)
  const closeBtn = modal.querySelector('.close');
  closeBtn.addEventListener('click', closeModal);
}

特に、フォーカスするDOMの保存とフォーカスを行き来する箇所についてがこの関数の肝になる。

focusableElementsStringの中にDOMをぶち込んでおり、その後サンプルの方ではArray.prototype.slice.callを使って、先程のDOMコレクションを配列に変換しています。

ただArray.prototypeなんて書きたくないので[]にして省略した。

focusableElements = [].slice.call(focusableElements)

サンプルではXボタンがなかったので、追加してXボタンがフォーカスされた状態でEnterを押すとモーダルが閉じるようになる。

後は、特に変えておらずアロー関数にしたことの弊害で、addEventListenerの呼び出し順番を少し変更したぐらいだ。

trapTabKey関数も基本同じ。キーイベントを行うときはpreventDefault()を使うことがお約束になっている点は注目箇所。

例えばこれに十字キーでも、フォーカス移動したいなどの要件が出た場合は追加でキーイベントを追加していく必要がありそうだがUIも考慮しないとつらいかもしれない。

写経して思ったこと

モーダルの実装は、比較的簡単ではあるが要件が膨らむと確かに考慮ポイントが多い。

特に今回は複数モーダルに対応していないが、要件によっては実装方法が変わったりするケースもあるし、今回みたいに複数のaddEventListenerはつらいので、リファクタリングする必要はありそうだ。

余談

そういえばdialogタグというのがHTMLの標準であるが、safariが実装していないし中々使えないものになっている。

https://developer.mozilla.org/ja/docs/Web/HTML/Element/dialog

多くのサイトは最低1つ何かしらのモーダルがUIとして提供されていると思うからブラウザのネイティブで実装が進んでほしいものである。


ソースコードはこちらのリポジトリにあります。

Google Analyticsを使っています。