Kohei Blog

夫・父親・医療系エンジニア

Reactでモーダルのフォーカス制御の自作にチャレンジした

アクセシビリティを極めていきたい駆け出しフロントエンドエンジニアです。

アクセシビリティな実装をする上で意識すべきことはたくさんありますが、中でもモーダルは考えることが多いなと思っています。

今回は、あえて react-modal などのライブラリを使用せず、自前でアクセシビリティなモーダルを実装してみました。

特に、モーダルを実装する上で肝となる「フォーカス制御」の部分に意識してあります。

アクセシビリティなモーダルとは?

こんなときは、WAI-ARIAのオーサリングプラクティスを参照します。

WAI-ARIAでは、ダイアログ(モーダル)と定義されていますね。

キーボードインタラクションについては以下のようなフローになります。

  • ダイアログが開いたら、フォーカスはダイアログの中に移動

  • タブキーを押したら、次の要素にフォーカスを移動

  • フォーカスがダイアログの中の最後の要素にあれば、ダイアログの中の最初のフォーカス可能要素に移動

  • シフト+タブキーの場合は、前の要素にフォーカスを移動

  • フォーカスがダイアログの中の最初の要素にあれば、ダイアログの中の最後のフォーカス可能要素に移動

  • エスケープキーを押したらダイアログを閉じる

  • ダイアログが閉じられるとき、そのダイアログが呼び出された要素にフォーカスを戻します

言語化するとめちゃくちゃややこしいですが、簡単に言うと「モーダルを開いたらフォーカスを閉じ込める」ということですね。

WAI-ARIAのルール

モーダル関係のWAI-ARIAルールも確認しておきます。

  • ダイアログのコンテナー要素はrole=dialogを持つ

  • ダイアログのコンテナー要素はaria-modalをtrueにセットする

roleを指定することで、スクリーンリーダーで「ダイアログ」と読み上げてくれます。 また、aria-modalをセットすると、ダイアログ以外の要素がアクティブでないことを支援技術に教えてくれるのでで、不要な読み上げが実行されなくなります。

WAI-ARIAのRole、States、Property

実際にReactでモーダルを作っていく

Reactでアクセシビリティなモーダルコンポーネントを作っていきます。

ここでは、モーダルのコンテンツ部分、CSSスタイリングは割愛しています。

import React, { useState, useCallback, useEffect } from 'react'
import styled from 'styled-components'

const COMPONENT_NAME = 'Modal'

type ModalElement = {
  firstElement: HTMLElement | null
  lastElement: HTMLElement | null
}

type ContainerProps = {
  className?: string
  isMounted: boolean
  modalClose: () => void
}

type Props = {} & ContainerProps

const Component: React.VFC<Props> = ({ isMounted, modalClose, className }) => (
  <>
    {isMounted ? (
      <div className={`${COMPONENT_NAME} ${className}`}>
        <div
          className='dialog'
          id='dialog'
          aria-modal
          role='dialog'
        >
        // Modal Contents
        </div>
      </div>
    ) : null}
  </>
)

const StyledComponent = styled(Component)`
// ...
`

const Container: React.FC<ContainerProps> = (containerProps) => {
  const { isMounted } = containerProps
  const [modalElement, setModalElement] = useState<ModalElement>({
    firstElement: null,
    lastElement: null,
  })

  const handleKeyDown = useCallback(
    (e: KeyboardEvent) => {
      if (e.key === 'Tab') {
        if (e.shiftKey) {
          if (document.activeElement === modalElement.firstElement) {
            modalElement.lastElement?.focus()
            e.preventDefault()
          }
        } else {
          if (document.activeElement === modalElement.lastElement) {
            modalElement.firstElement?.focus()
            e.preventDefault()
          }
        }
      }
    },
    [modalElement.lastElement, modalElement.firstElement]
  )

  useEffect(() => {
    if (isMounted) {
      const focusableEls = document.querySelectorAll(
        '.dialog a[href]:not([disabled]), .dialog button:not([disabled]), .dialog textarea:not([disabled]), .dialog input[type="text"]:not([disabled]), .dialog input[type="radio"]:not([disabled]), .dialog input[type="checkbox"]:not([disabled]), .dialog select:not([disabled])'
      )

      const firstElement = focusableEls[0] as HTMLElement
      const lastElement = focusableEls[focusableEls.length - 1] as HTMLElement

      setModalElement({
        firstElement,
        lastElement,
      })

      firstElement.focus()
      document.addEventListener('keydown', handleKeyDown)
    }

    return () => {
      document.removeEventListener('keydown', handleKeyDown)
    }
  }, [isMounted, handleKeyDown])

  return <StyledComponent {...containerProps} />
}

export default Container

Containerコンポーネントの部分で、フォーカスの制御を行っています。

モーダル内にフォーカスを制御させる


  const handleKeyDown = useCallback(
    (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        modalClose()
      }
      if (e.key === 'Tab') {
        if (e.shiftKey) {
          if (document.activeElement === modalElement.firstElement) {
            modalElement.lastElement?.focus()
            e.preventDefault()
          }
        } else {
          if (document.activeElement === modalElement.lastElement) {
            modalElement.firstElement?.focus()
            e.preventDefault()
          }
        }
      }
    },
    [modalElement.lastElement, modalElement.firstElement, modalClose]
  )

  1. タブキーでの移動またはタブ+シフトキーでの移動

  2. document.activeElementで現在フォーカスがあたっている要素を取得

  3. モーダル内の最初又は最後のフォーカス可能要素がアクティブだった場合、最初又は最後の要素にフォーカスを移す。

  4. ESCキーが押されたら、モーダルを閉じる

フォーカスしている要素とモーダル内のフォーカス可能な子要素をチェックして、モーダル内でのフォーカス制御を実現できました。

参考にしたのはこちらの記事


  useEffect(() => {
    if (isMounted) {
      const focusableEls = document.querySelectorAll(
        '.dialog a[href]:not([disabled]), .dialog button:not([disabled]), .dialog textarea:not([disabled]), .dialog input[type="text"]:not([disabled]), .dialog input[type="radio"]:not([disabled]), .dialog input[type="checkbox"]:not([disabled]), .dialog select:not([disabled])'
      )

      const firstElement = focusableEls[0] as HTMLElement
      const lastElement = focusableEls[focusableEls.length - 1] as HTMLElement

      setModalElement({
        firstElement,
        lastElement,
      })

      firstElement.focus()
      document.addEventListener('keydown', handleKeyDown)
    }

    return () => {
      document.removeEventListener('keydown', handleKeyDown)
    }
  }, [isMounted, handleKeyDown])

ここで行っているのは、マウント時にフォーカス可能要素を取得してフォーカスを当てる処理と、イベントリスナーの設定です。

  1. モーダルがマウントされたとき、モーダル内のフォーカス可能要素を取得

  2. そのうち最初と最後の要素をStateに格納

  3. 最初の要素にフォーカスを当てる

  4. keydownイベントでhandleKeyDown関数を発火させる

  5. アンマウント時にイベントリスナーを削除

モーダルを閉じたときにフォーカスを元に戻す

最後に、モーダルを閉じたときにフォーカスを元のページに戻す処理です。

便利なuseRefを使い、モーダルを閉じた際にフォーカスを元に戻す処理を書いているだけです。

  const modalButtonRef = useRef<HTMLButtonElement | null>(null)

  const modalClose = useCallback(() => {
    setIsMounted(false)
    modalButtonRef.current?.focus()
  }, [setIsMounted])

モーダルを開くトリガーになっているボタンにフォーカスを戻すのが通常のようですので、今回はボタンにrefを設定しました。

        <button type='button' onClick={modalOpen} ref={modalButtonRef}>
          モーダルを開く
        </button>

動作確認

さて、これで問題無さそうか動作確認してみます。

モーダルを展開した後、フォーカスがモーダル内に制御されます。

ESCキーでモーダルを閉じた後は、「モーダルを開く」ボタンにフォーカスが戻っていて、ユーザーが困惑することも無さそうです。

ここまで自作でフォーカス制御を実装してきましたが、focus-trap-reactを使うと簡単に実装できます。

アクセシビリティの深みへ

単純にデザインを実装するだけではなく、アクセシビリティを追求して細かいUIを作っていくのは興味深いです。

とはいえ、本日実装したものでは不十分な部分もあるかもしれないので、まだまだアクセシビリティに関する知見は吸収しつつアウトプットしていきたいですね。