🔍

【番外編】Focus Management APIについて(概要編) - React Ariaの実装読むぞ

2024/12/17に公開

こんにちは、フロントエンドエンジニアの mehm8128 です。
今日は Focus Management API の概要について書いていきます。

Focus Management API とは

Focus Management API とは、こちらの React の RFC で提案されている API です。提案者の方が React Spectrum のメンテナーということと、React Aria で同様の API が実装されていることから、今回紹介することにしました。

https://github.com/reactjs/rfcs/pull/109

RFC 自体はここから見ることができます。

https://github.com/devongovett/rfcs-1/blob/patch-1/text/2019-focus-management.md

簡単に言うと、FocusScopeコンポーネントとFocusManagerという API を react-dom にビルトインで導入したいという提案です。
React の createPortalが抱えている問題の改善や、その他フォーカス制御をいい感じにしたいというのが主な目的です。

まだreact-domには入っていないのですが、前述のようにFocusScopeFocusManagerも React Aria には導入済みです(ただ、a hacky DOM-based implementation らしいです)。詳細は明日の記事で紹介します。

https://react-spectrum.adobe.com/react-aria/FocusScope.html

現状の辛さ

Challengesのセクションに、現状フォーカス制御をすることの辛さが語られています。観点ごとに整理して見ていきます。

Focus containment

宣言的な React で、命令的な処理を書くことになる ref(useRef)を使うのはエスケープハッチとされています。特にフォーカス制御などで ref を使わざるを得ないときがありますが、ref は React っぽくないのであまり使いたくないとのことです。
ref を使う場面の例として、例えばダイアログやその他ポップアップで、フォーカスが外に出てしまわないようにしたいことがあります。これを Focus containment を呼んでいます。ダイアログ内の最後の要素にフォーカスしている状態で Tab キーを押したら、ダイアログ内の一番上の要素にフォーカスが戻るようにしたりといったものです。これは現状、手動で命令的にフォーカスを制御しなければ実装できません。

Restoring focus

現状フォーカスを移動するとき、前にどの要素がフォーカスを持っていたかを記憶しておくには上記の ref などを用いて手動で管理しておくしかありません。前にフォーカスしていた要素を記憶していたい場面の例を 2 つ紹介します。

リストとかグリッドといった UI パターン(参考: GridList について - React Aria の実装読むぞ)では、一度 Tab キーでフォーカスしたらその後の、その UI の中でのフォーカス移動は矢印キーで移動したいです。なぜなら、見たいわけではないグリッドにフォーカスしたときに、そこから抜け出して次のコンテンツに進むために何回も Tab キーを押さなければならないからです。
そのために Roving tab index パターンが上記の動作を実現する 1 つの手段ではありますが、一度フォーカスが外れてまた戻ってきたときに、前にフォーカスされていた要素にフォーカスを復元するにはそれを記憶しておかないといけません。

また、ダイアログが閉じたときに、ダイアログを開く前にフォーカスしていた要素(ダイアログのトリガーボタンなど)にフォーカスを戻したいこともあります(参考: Popover と Dialog について - React Aria の実装読むぞ)。

このように、前にフォーカスしていた要素に再度フォーカスを復元したいような場合に、ある領域の中で最後にフォーカスしていた要素を記憶しておく必要があります。

React portals

React portals を利用したときは実際の DOM の順番が React tree(ソースコード上のツリー)と異なるので、フォーカス順についても開発者の意図しない動作をしてしまうことがあります。
RFC に書かれているソースコードの例をそのまま取ってきます。

function App() {
  return (
    <div>
      <input placeholder="input 1" />
      <Portal>
        <input placeholder="input 2" />
      </Portal>
      <input placeholder="input 3" />
    </div>
  );
}

Portalコンポーネントは、createPoratlを用いて子要素をdocument.bodyに配置させるようなものだと想定しています。
この場合、フォーカス順がinput 1input2input3ではなくて、input 1input3input2となります。これは分かりづらいので、React tree の順にフォーカスされるようにしてほしいということが提案されています。実はイベントバブリングは React tree の通りに行われるらしいです。つまり、input 2で発生したイベントはその親要素であるdivタグにバブリングされる、ということです。

解決方法

上記の辛さを踏まえて、Detailed design のセクションでは今回の提案でどのように問題を解決していくかが述べられています。

言葉の定義

Definitions のセクションで用語の定義がされています。Radio と Checkbox について - React Aria の実装読むぞでも出てきましたが一応確認します。

focusable: デフォルトでフォーカス可能なinputbutton要素に加えて、tabindex属性がついている要素
tabbable: デフォルトでフォーカス可能なinputbutton要素に加えて、値が 0 以上のtabindex属性がついている要素(つまり、Tab キーでフォーカスできないマイナスのtabindexを持つ要素は含まない)

FocusScope

React root に暗黙のFocusScopeを用意しておき、FocusScopeはその中にあるフォーカス可能要素で順序付きリストを作成します。Tab キーを押したときに、この順序通りにフォーカスが移動していきます。
FocusScopeはその中で最後にフォーカスされた要素を記憶しておき、他のFocusScopeからフォーカスが移動してきたときにその位置にフォーカスを戻すことができるようにします。また、現在フォーカスされている要素を持っているFocusScopeがアンマウントした場合、そのFocusScope外の最後にフォーカスを持っていた要素にフォーカスが移動します。

さらに、FocusScopeは Focus containment にも用いることができます。containprop を渡した場合、FocusScope内のフォーカス可能要素の中でフォーカスがループします。そして、autoFocusprop を渡すとFocusScope内で最初のフォーカス可能要素に自動でフォーカスします。

FocusManager

矢印キーを押したときのフォーカス移動など、Programmatically にフォーカスを移動するための API で、next, previous, first, last の focusable 及び tabbable な要素への移動をサポートしています。特定の要素へのフォーカスは引き続き React のrefを使うのがよさそうとのことです。

その他

残りのセクションでは、具体的な実装方針や使用例がソースコードとともに紹介されています。

まとめ

明日の担当は @mehm8128 さんで、 番外編 Focus Management API について(実装編)の記事です。お楽しみにー

Discussion