ReactのContextを使ったtabindex制御

#はじめに

Web のアクセシビリティの話でまず出てくるtabindex属性、皆さんちゃんと使っていますでしょうか。
特にスタイルに凝っていない、普通の web サイトを作る場合は特に指定しなくても勝手にいいかんじに動いてくれる※1ので、もしかしたら使ったことがない人もいるかもしれません。

この属性は Web アクセシビリティ的には非常に重要となります。
しかし、特に SPA を実装する場合なんかに割と無視されがち※2なうえ、単純だけどちゃんと設定しようと思うと地味に面倒なんですよね。

この記事では React を使ったアプリケーションにおいて、簡単、直感的かつ柔軟にtabindexを管理する方法について説明します。

なお、inertを使う場合(要polyfill)はこの記事の内容は必要なくなります。

※1 ... :focusのスタイルを外したりしない前提
※2 ... 筆者の体感です

#想定読者

  • React を使ったアプリケーションを書ける
  • React の Context API についてある程度理解している
  • tabindex属性がどんなものか知っている(tabindex at MDN)

#TL;DR

  • TabNavigatableContext(boolean)のようなコンテキストを作成する
  • タブによるフォーカス可能な要素はそのコンテキストの値を見てtabindex0-1を設定する
  • あるコンポーネント以下の要素全てにフォーカスを巡らせたくない場合は<TabNavigatableContext.Provider value={false}>で要素を包む
  • モーダルがある場合は Provider 内で参照 or トップレベルコンポーネントで制御
const Button = () => {
  const isTabNavigatable = useContext(TabNavigatableContext)

  return <button tabIndex={isTabNavigatable ? 0 : -1}>BUTTON</button>
}

const App = () => {
  const [modalVisible, setModalVisible] = useState(false)

  return (
    <TabNavigatableContext.Provider value={!modalVisible}>
      <TabNavigatableContext.Provider value={modalVisible}>
        <Modal visible={modalVisible}>
          <Button />
        </Modal>
      </TabNavigatableContext.Provider>
      <Button />
    </TabNavigatableContext.Provider>
  )
}

#タブナビゲーション可能かどうかのコンテキスト

このテクニックの中心は、「コンテキスト内の要素のフォーカス可/不可」を提供するコンテキストです。

// コンテキスト外で使った際にフォーカスが当たらなくなってしまうのを防ぐため
// 初期値はフォーカスが当たるようにしている
const TabNavigatableContext = createContext(true)

// コンテキストのプロバイダ
const TabNavigatableProvider = ({ children, navigatable = true }) => {
  return (
    <TabNavigatableContext.Provider value={navigatable}>
      {children}
    </TabNavigatableContext.Provider>
  )
}

このコンテキストは navigatableの値を伝播するだけのものとなります。
そして、フォーカスが当たる可能性のある要素全てでこのコンテキストの値を参照します。

const MyButton = props => {
  // navigatable: boolean
  const navigatable = useContext(TabNavigatableContext)

  return <button tabIndex={navigatable ? 0 : -1} {...props} />
}

あとは普通にコンポーネントを使うだけです。
もし、子要素にフォーカスを当てたくないコンポーネントがある場合は、TabNavigatableProviderで子要素を優しく包み込んであげましょう。

const WhimsicalUI = ({ children }) => {
  const navigatable = useContext(TabNavigatableContext)

  const [visible, setVisible] = useState(true)

  return (
    <div
      style={{
        opacity: visible ? 1 : 0,
        pointerEvents: visible ? 'all' : 'none'
      }}
    >
      <TabNavigatableProvider navigatable={visible}>
        {children}
      </TabNavigatableProvider>
    </div>
  )
}

#モーダル表示中にモーダル外にフォーカスを移動させない

モーダルダイアログ UI では、ダイアログの外にフォーカスを回さないようにするのが一般的です(トラップ)。<dialog>要素を使うことで簡単に実現できますが、ブラウザ対応状況が芳しくないため、まだまだ実用は難しい状況です。

しかし、このコンテキストを使えば、はためんどくさいトラップの実装が簡単にできてしまいます。

// モーダルが開いているかどうかの状態を保持するだけのコンテキスト
// トップレベルでモーダルを管理していたり、別の機構がある場合は必要なくなる場合も
const ModalContext = useContext({
  exists: false,
  open: () => 0,
  close: () => 0
})

const ModalProvider = ({ children }) => {
  const [count, setCount] = useState(0)

  const value = useMemo(
    () => ({
      exists: count > 0,
      open() {
        setCount(prev => prev + 1)
      },
      close() {
        setCount(prev => prev - 1)
      }
    }),
    [count]
  )

  return <ModalContext.Provider value={value}>{children}</ModalContext.Provider>
}

// コンテキストのプロバイダにもちょっと手を加える
const TabNavigatableProvider = ({
  children,
  navigatable = true,
  force = false
}) => {
  const modal = useContext(ModalContext)

  const value = (force || !modal.exists) && navigatable

  return (
    <TabNavigatableContext.Provider value={value}>
      {children}
    </TabNavigatableContext.Provider>
  )
}

あとは、モーダル UI となるコンポーネントでModalContextを操作すれば完成です。

const Modal = ({ open, children }) => {
  const modal = useContext(ModalContext)

  useEffect(() => {
    if (!open) {
      return
    }

    // 開く際にコンテキストのメソッドを呼ぶ
    modal.open()

    // 閉じる際にもコンテキストのメソッドを呼ぶ
    return modal.close
  }, [open])

  return (
    <div className="backdrop">
      <div className="container">
        {/* forceがないとnavigatableの値が無視されてしまう */}
        <TabNavigatableProvider navigatable={open} force>
          {children}
        </TabNavigatableProvider>
      </div>
    </div>
  )
}

モーダルをどのように実装するかによって詳細な実装は変わってきますが、基本的に<TabNavigatableProvider>の中でモーダルが存在しているかどうかを知り、存在していれば値を強制的にfalseにするだけです。

DOM を走査してフォーカス可能な要素を取得してtabindexを変えて...とやるよりは、宣言的に書くことができるためバグも入りにくく、予期せぬ挙動が起きにくくなります。

#おわりに

このテクニックを使えば、子要素をプロバイダで囲うだけで簡単かつ柔軟にtabindexの制御を行うことができます。また、プロバイダの中にさらに条件等を追加することも可能です。

しかし、簡単になっても「いかに開発者がアクセシビリティを意識できるか」が重要であることには変わりません。tabindexはアクセシビリティ対応としてだけではなく、一般のユーザに向けた操作性向上にもなるのでしっかりと意識して実装していきましょう。