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 のList
component を使う。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.previousElementSibling
とevent.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 のList
component を使うと、中が li にすぐになっていればいいけど、実際は div をかましてリストアイテムの position の計算とかをしているので、ul>div>div>li
みたいになってしまい、OptionList を ul、OptionListItem を li というような単純なことにはできなかった。そのため div にしている。
DOM なければ focus 当たらないので mount,unmount をさせない。
CSSTransition には props にmountOnEnter
とunmountOnExit
があり、不要になったら DOM から消して、必要になったら DOM に追加するということができる。
これをやると、Control 選択後にオプションメニューのアイテムに focus をあてることが、DOM がないのでできなくなってしまう。具体的には useRef 使った ref で menuItemRef.current.focus()
ってことが、DOM がないのでできなくなってしまう。そのため、mountOnEnter
とunmountOnExit
は使わず、opacity: 0; pointer-events: none;
で見えないように操作できないようにしてる。オプションメニューアイテムも、開いてなければ tabIndex=-1 で focus できないようにしてる。
keycode を使ったけど使わなければよかった
KeyboardEvent ではevent.keycode
が使えるけど、deprecated になっているので、event.key
かevent.code
を使ったほうが良い。3 者の違い、ブラウザ間の違いは調べていないので要調査。
作ってみて
めんどくさいけど、できなくはないなーという印象。
material ui 結構使ってたので、改めて見直してたら focus とかオプションメニューを出す位置とか工夫されてて感心した。
オプションメニューの focus と hover で色味が若干違うのは良さそうだったので考え拝借した。
作ったあとにReact Ariaがイベント操作系の hooks いっぱい持ってたの気がついて、これでロジックだけ拝借したら focus とかキーボード操作系簡単にできそうだと思った。
aria 属性全く知らないので今回作ったセレクトボックスには role とか aria とかは全くと言っていいほど書けてない。要調査。
special thanks
- asca
- wai-aria の考え方、aria-hidden の使い方教えていただいた。
- ゆうてん 🖖
- WAI-ARIA オーサリング・プラクティス 1.1 の存在を教えていただいた。