Reactでキーボード操作も考慮したvertualized scroll対応のセレクトボックスを作る

select element と option element を使うとブラウザで提供してくれるセレクトボックスを自分で 1 から作ってみたのでメモ。

なんでやったの

  • react-virtualizedを使って、結構多めの選択肢を表示する(オプションメニュー)際のレンダリングパフォーマンスが考慮されたセレクトボックスを作りたかったから。
  • アクセシビリティ的に使いやすいフォームってどんなのだっけ?っていうのを自分で考えて実装してみたかったから

どこにあるの

Sandbox React Virtualized | react-virtualized で色んなものを作る

この記事を書いたときの repo はここ
GitHub - tyankatsu0105/sandbox-react-virtualized at 2c7a71c01a2b257becdc3eb3d18f77793f8c6844

作っていく

採用技術は以下の通り

  • nrwl/nx
  • styled components
  • react-virtualized
  • Next.js

大まかな DOM 構成

component
└── div.Wrap
    ├── div.Control
    │   ├── input.HiddinInput
    │   ├── p.Placeholder
    │   └── div.OpenToggle
    └── CSSTransition
        └── div.OptionListWrap
            └── div.OptionList
                └── ReactVirtualizedAutoSizer
                    └── ReactVirtualizedList
                        └── div.OptionListItem

大枠 Wrap は position: relative;

中で option menu を出すときにposition: absoluteするために、position: relativeを指定した大枠を作る。

Control はオプションメニューを出すきっかけの箇所

クリック、もしくは enter をクリックするとオプションメニューを出すコントロール部分を作る。
ここは tabIndex=1 で tab focus が効くようにし、enter をクリック、またはクリックでオプションメニューを出すように onKeyDown と onClick にハンドラを渡す。

input を使うが opacity: 0;

Control の内部で input を置いているが、実際にこれは操作対象にはしない。
input を使うと style が自由に作れないのでopacity: 0;で aria-hidden してる。
value は渡すようにした。
また、こいつに tab focus をさせたくなかったので、tabIndex=-1 で tab focus を切った。
また、レイアウトに影響も与えたくないのでposition: absolute;で浮かせてる。
hover で cursor に種類が変わらないようにするためにpointer-events: none;で操作対象にとにかくさせないようにしてる。
cursor: inheritでもいいけど、ここは material ui の Select component を真似た。

OptionListWrap で表示非表示のスタイルを当てる

オプションメニュー表示非表示時のスタイルを自由に当てるために、react-transition-group の CSSTransition を使ってる。

ReactVirtualizedAutoSizer を使ってサイズの可変に対応させる(今回は高さ固定)

react-virtualized は高さか幅をグリグリと可変することができる HOC のAutoSizerを提供している。
しかし高さか幅のどちらかは固定値にしないとだめなので、今回は高さを固定で縦スクロールできるようにし、幅は可変にさせた。OptionListWrap は高さ固定のための wrap component としている。

ReactVirtualizedList でオプションメニューに vertualized scroll を導入

react-virtualized のListcomponent を使う。rowRendererという renderProps が使えて、リストアイテムのスタイルを自由に変えられるので使用する。

OptionListItem の data-value を value として使い、その値を value として使う

OptionListItem は div なので、data 属性で value 値を持たせ、click か enter を押すとevent.currentTarget.dataset.valueで値を抜き出して value を保持する state に渡すようにしてる。

OptionListItem の選択を tab、arrowUp、arrowDown で可能にする

tab は tabIndex=1 で可能。
同じ親を持つ兄弟要素の前後はevent.currentTarget.previousElementSiblingevent.currentTarget.nextElementSiblingで取得可能なので、arrowUp を押したらevent.currentTarget.previousElementSibling.focus()、arrowDown を押したらevent.currentTarget.nextElementSibling.focus()、を実行されるように、OptionListItem の onKeyDown props にハンドラを渡す。

tips とか困ったこととか

focus を当てる箇所を考える。

  • Control をクリック、もしくは enter を押すと、次の focus はどこだといいのか
  • オプションメニューの focus 初期値は value の有無で変えるのか
  • オプションメニュー選択後の focus はどこだといいのか
    を考え、
  • Control をクリック、もしくは enter を押すと、オプションメニューのリストの一番最初に focus を当てる。
  • value がなければオプションメニューのリストの一番最初に focus を当て、value がすでにあればその value を持つリストアイテムに focus を当てておく。
  • オプションメニューで選択し終わったあとは focus を Control に戻す。

とした。

オプションメニューのリストに ul,li は使用難しい

react-virtualized のListcomponent を使うと、中が li にすぐになっていればいいけど、実際は div をかましてリストアイテムの position の計算とかをしているので、ul>div>div>liみたいになってしまい、OptionList を ul、OptionListItem を li というような単純なことにはできなかった。そのため div にしている。

DOM なければ focus 当たらないので mount,unmount をさせない。

CSSTransition には props にmountOnEnterunmountOnExitがあり、不要になったら DOM から消して、必要になったら DOM に追加するということができる。
これをやると、Control 選択後にオプションメニューのアイテムに focus をあてることが、DOM がないのでできなくなってしまう。具体的には useRef 使った ref で menuItemRef.current.focus()ってことが、DOM がないのでできなくなってしまう。そのため、mountOnEnterunmountOnExitは使わず、opacity: 0; pointer-events: none;で見えないように操作できないようにしてる。オプションメニューアイテムも、開いてなければ tabIndex=-1 で focus できないようにしてる。

keycode を使ったけど使わなければよかった

KeyboardEvent ではevent.keycodeが使えるけど、deprecated になっているので、event.keyevent.codeを使ったほうが良い。3 者の違い、ブラウザ間の違いは調べていないので要調査。

作ってみて

めんどくさいけど、できなくはないなーという印象。
material ui 結構使ってたので、改めて見直してたら focus とかオプションメニューを出す位置とか工夫されてて感心した。
オプションメニューの focus と hover で色味が若干違うのは良さそうだったので考え拝借した。
作ったあとにReact Ariaがイベント操作系の hooks いっぱい持ってたの気がついて、これでロジックだけ拝借したら focus とかキーボード操作系簡単にできそうだと思った。
aria 属性全く知らないので今回作ったセレクトボックスには role とか aria とかは全くと言っていいほど書けてない。要調査。

special thanks