テーブルの仮想スクロールとスクリーンリーダー向けのアクセシビリティ
Webアプリケーションで、大量のデータを表示したいときに使われる、「仮想スクロール」と呼ばれるテクニックがあります。
大量のデータを素直にDOMに挿入してしまうと、レンダリングの処理に非常に負荷がかかり、場合によってはブラウザをフリーズさせてしまったりします。そこで使われるのが「仮想スクロール」です。スクロール位置に応じて、視覚的に見える範囲のデータのみをDOMに挿入することで、レンダリング処理を最小限にするというものです。
この仮想スクロールについて、直感的にスクリーンリーダーでの閲覧に耐えられるのかの不安を感じました。しかし、あまりテーブルを仮想スクロールする場合についてのまとまった情報をWeb上で発見することができませんでした。
そこで、実際に仮想スクロールを採用した検証用のWebアプリケーションを作成し、スクリーンリーダーでの動作を確認してみることにしました。
「日本の郵便番号」アプリケーション
作成したアプリケーションは日本の郵便番号です。
これは住所の郵便番号(1レコード1行、UTF-8形式)(CSV形式) を見やすい形にしただけのアプリケーションですが、そのレコード数は124369件あり、仮想スクロールの導入が必須の規模です(「開発者設定」から仮想スクロールを無効にできますが、ブラウザによってはチェックを入れようとした瞬間にフリーズしてまいます)。
実装にはReactとTanStack Virtual(@tanstack/react-virtual)を使用しました。
display: table
を使用しないテーブル
table要素 / 仮想スクロールを使ってテーブルを実装するときに、「table要素が使えない」「display:gridを使用したい」ということが現実に発生することが予想されます。
table要素は、その中にthead要素とtbody要素を、それぞれの中にtr要素を、その中にtd要素やth要素を入れて使用します。これらは直接の親子関係になっている必要があって、たとえば「tbody要素の中にdiv要素を入れてその中にtr要素を入れる」みたいなことはHTMLの仕様では許されていません。
また、これらのtable関連の要素が使うCSSの display
プロパティは、 table
table-row-group
table-header-group
table-row
table-cell
など多岐にわたります。 仮想スクロールのような「ややこしい」ことをしようとすると、これらの構造やdisplay
プロパティの制約が実装を困難にしてしまうことが考えられます。
今回の実装は、table要素や display: table
を敢えて使用せず、表・ヘッダー・ボディ・行・セルを構成するのはすべてdiv要素とし、1行ずつに display: grid
を使用してセルを配置するような構成にしてあります。 display: table
を使用しないことで、小さな画面向けには1つのデータが途中で折り返され、2行で1つのデータを表現するような見せ方もできるようになっています。
roleを使用して、表であることを伝える
このようにtable要素を使わずにテーブルを実装してしまうと、当然、「その部分が表であること」をスクリーンリーダーに伝えることができなくなってしまいます。そこで、role
属性を使って情報(WAI-ARIAロール)を補ってやる必要が出てきます。
WAI-ARIA(日本語訳)では、表(っぽいもの)のロールとして、 table
または grid
が用意されています。table要素を使った場合のロールは table
となり、 grid
はよりインタラクティブなUIコンポーネントとして想定されているようです。
table
でも grid
でも、rowgroup
と row
、そしてth要素に相当するcolumnheader
rowheader
は共通で、セルについてはそれぞれ cell
と gridcell
が用意されています。
WAI-ARIA属性を使って、表の大きさと位置を伝える
テーブルを仮想スクロールさせるにあたっての大きな懸念は、「いま巨大なテーブルのごく一部を読んでいる状態であること」「巨大なテーブルのどの位置をいま読んでいるのか」がスクリーンリーダー利用者に正しく伝わるかどうかです。
視覚的にはテーブル自体が大きくレンダリングされ、スクロールバーがページの長さや見ている場所の位置を表現してくれますが、DOM上にあるのは表示領域付近の行だけです。なにもしなければ、スクリーンリーダーからは小さなテーブル上の数行目にいるように見えてしまいます。
WAI-ARIAでは、これらを4つの属性を使って伝えられるようになっています
- aria-rowcount: 表の行数
- aria-colcount: 表の列数
- aria-rowindex: 表上の位置(どの行か)
- aria-colindex: 表上の位置(どの列か)
これらを使って、以下のような形で、ひとまずスクリーンリーダーで読めそうな形の仮想スクロールを実装することができました(コードをかなり簡略化して示しています)。
import { useWindowVirtualizer } from "@tanstack/react-virtual";
export const Table = () => {
// Tanstack Virtualのhooksを呼ぶ
const rowVirtualizer = useWindowVirtualizer({
count: data.length, // データの件数
estimateSize: () => 48 // 1行あたりの高さ
overscan: 10 // 表示される行の前後に多めに作っておく行数
});
// スクロール位置を元にして、表示するアイテムのみを取得する
const items = rowVirtualizer.getVirtualItems();
return (
<div
role="table"
aria-label="日本の郵便番号"
aria-colcount={5}
// データの件数 + ヘッダー行
aria-rowcount={data.length + 1}
// 全アイテムぶんの高さを保持させる(スクロールする長さを確保するため)
style={{minHeight: rowVirtualizer.getTotalSize()}}
>
<div role="rowgroup">
<div role="row" aria-rowindex={1}>
<div role="columnheader" aria-colindex={1}>郵便番号</div>
<div role="columnheader" aria-colindex={2}>都道府県</div>
<div role="columnheader" aria-colindex={3}>市区町村</div>
<div role="columnheader" aria-colindex={4}>町域</div>
<div role="columnheader" aria-colindex={5}>その他の情報</div>
</div>
</div>
{/* 見えてる部分のセルを描画する。translateで下にずらして、ユーザーがスクロールしている場所に配置されるようにする */
<div role="rowgroup" style={{translate: `0 ${items[0]?.start ?? 0}`}}>
{items.map((item) => (
// 行ごとの描画
<div
role="row"
key={item.key}
// indexが0始まりで、ヘッダー行ぶんもあるので2を足している
aria-rowindex={item.index + 2}
>
<div role="rowheader" aria-colindex={1}>{item...}</div>
<div role="cell" aria-colindex={2}>{item...}</div>
<div role="cell" aria-colindex={3}>{item...}</div>
<div role="cell" aria-colindex={4}>{item...}</div>
<div role="cell" aria-colindex={5}>{item...}</div>
</div>
)}
</div>
</div>
)};
スクリーンリーダーに読ませてみる
JBICT.netの第3回支援技術利用状況調査によると、日本の視覚障害者の使用するスクリーンリーダーは、PC-Talkerのシェアの高さが目立ちます。また、わたしの周囲の視覚障害者はNVDAを使っている人も多くいます。
Web開発者の動作確認環境は、WindowsならWindowsに組み込まれているナレーター、クリエイター版 PC-Talker Neo Plus(音声出力機能がない)やNVDAが入手性が高く、またmacOSの場合はmacOSに組み込まれているVoiceOverが唯一の選択肢となります。
今回は以下のような組み合わせを試してみました。
- ナレーター + Microsoft Edge: Windows PCすべてに搭載されているであろう組み合わせ
- クリエイター版 PC-Talker Neo Plus + NetReader Neo(PC-Talker専用ブラウザ): 日本の視覚障害者ユーザーの使用を想定すると、無視できない割合のユーザーがいるであろう組み合わせ
- NVDA + Microsoft Edge, Google Chrome, Mozilla Firefox: PC-TalkerよりもNVDAのほうがWAI-ARIAへの対応度の高さが期待できる
- VoiceOver + Google Chrome: Macを使う開発者がよく使うであろう組み合わせ
- VoiceOver + Safari: VoiceOver標準の組み合わせ
それぞれのスクリーンリーダーとブラウザの組み合わせで、以下のような観点で見ていきます
- テーブルであることをユーザーが認識することができるか
- テーブル全体で、何列・何行のデータがあることを、ユーザーが認識できるか(
aria-count
aria-colcount
を認識できるか) - テーブル内を移動する機能を利用できるか(上下左右のセルに移動することができるか)
- テーブル内の移動中に、どの列・どの行にいるのかを認識できるか(
aria-colindex
aria-rowindex
を認識して、rowheader
colheader
の内容を同時に読むことができるか) - スクロールが発生しても、連続して読んでいくことができるか
ナレーター + Microsoft Edge
ナレーターとMicorosoft Edgeの組み合わせでは、NVDAとEdgeやChromeの組み合わせでも発生する、テーブルに到達したタイミングで中央付近にスクロールしてしまう問題が発生する上、テーブル行内での移動が上手くいかない状態でした。
- テーブルに辿りついたときに「表、カラムス」に続いてヘッダー列の内容を読み上げ、さらに「124370エックス5」と、表の大きさを読み上げる
- テーブルに辿りついた瞬間に、真ん中あたりまでスクロールしてしまう。いきなり岐阜県のあたりが表示された状態になってしまう
- Ctrl+Alt+矢印キーでテーブル内の移動をする機能はあるが
- ヘッダー行内では左右への移動は正しく機能する
- データ行に入ると、左右への移動は「行内の前のセルがありません」「行内の次のセルがありません」、下への移動はしばらく黙ったのち「列内の次のセルがありません」、上への移動しばらく黙ったのちかなり離れた行のセルを読み上げる
- セルへの移動時には「列ヘッダー 市区町村 5の列3」のように列ヘッダーを読みあげる。行を移動したら「124370の行62170の列1」のように現在地を読み上げる
- セルの移動をしても行ヘッダーを読み上げるような挙動はない
- 下矢印キーを使い、すべてのセルを読んでいくことになるが、スクロールが発生しても次々と行を移動していくことができる
クリエイター版 PC-Talker Neo Plus + NetReader Neo
PC-Talker Neo PlusとNetReader Neoの組み合わせでは、WAI-ARIAの属性へ対応できていない状況が目立つ結果となりました。特に aria-rowindex
を解釈できないため、現在何行目を読んでいるのかがわからない点が、仮想スクロールのある巨大なテーブルを読む上で問題となり得そうです。
- テーブルに到達すると「インサートキーでテーブルモード」と表示される。Insertキーを押すことで、矢印キーによってセルを移動していくことができる
- 行数・列数に関する情報を表示する方法は見つけられなかった
- セルを移動していくこと、「○行」のように行数が表示されるが、これは
aria-rowindex
の情報ではなく、DOM上の行数であると思われる(ずっと20行目あたりにいる状態になる)- Shift + F9でセルの位置を読み上げさせられるが、これで表示されるのも同じものの模様
- 表の見出しを読み上げるかどうかは設定項目として存在して、ONにしていればセル移動時に見出しセルの内容が表示される。Ctrl+F9でいつでもON/OFFできる
- 下方向にセルを移動していくと、テーブルの終わりを示す警告音が鳴ることがあるが、再度下方向に移動する操作をすることで、次の行へと読み進めることができる
NVDA + Microsoft Edge, Google Chrome, Mozilla Firefox
NVDAとMozilla Firefoxの組み合わせは、今回試したなかでは最も快適に使える状態でした。Microsoft EdgeやGoogle Chromeでは、ナレーターでも発生した中央付近にスクロールしてしまう問題が発生します。
- テーブルに辿りついたとき、「テーブル 124370行5列のテーブル」と、テーブルの大きさを読み上げる
- Micorosoft EdgeとGoogle Chromeは、テーブルに辿りついた瞬間に、真ん中あたりまでスクロールしてしまう。いきなり岐阜県のあたりが表示された状態になってしまう(Firefoxでは発生しない)
- Ctrl+Alt+矢印キーにより、上下左右のセルに移動することができる
- セルへの移動時、
- 列を移動した場合は、「町域 4列 北一条東」のように列のヘッダーの情報と位置を読み上げる
- 行を移動した場合にも「060-0031 7行 北一条東」のように行のヘッダーと位置の情報を読み上げる
- 高速で次々と行を送っていくと、まれに10行程度(おそらく多めに作っている
overscan
の行数)飛んだ場所に移動することがある- いきなり飛んだ行数を読み上げるので、これが発生していることには気付きやすく、また上方向にカーソルを動かしていけば、飛ばされた行を読むことができる
VoiceOver + Chrome, Safari
日本の視覚障害者の使用するスクリーンリーダーとしてはシェアがかなり少ないmacOSのVoiceOverですが、今回のようなテーブルを読んでいくのはかなりの困難がありそうでした。
- テーブルに到達すると、「日本の郵便番号、表、5列、124370行」と、テーブルの大きさを読み上げる。
- ただしGoogle Chromeでは「表」は「おもて」と読む(こういう漢字の読み方が変になることはよくあるものなのでそんなに気にしなくてOKです)。Safariではキャプションパネルに表示される文言が「表」ではなく「ひょう」となっているため、「ひょう」と読める」
- テーブル内のセルの移動は、VoiceOverキー(CapsLockまたはControl + Optionキー)と矢印キーの同時押しで、上下左右に移動できる
- 行を移動したときには「行7 / 124,370 060、-、0031 北一条東」のように、どの行なのかと、行見出しを読んだ上で、その列の内容を読み上げる
- 列を移動したときには、「市区町村 札幌市中央区 列 3 / 5 」のように、列見出しを読んだ上で、その列の内容を読み上げる
- セルを下方向に辿っていくと、スクロールせずに見えている範囲内のいちばん下まで読むと、次の列の最初の行に移動し、いちばん右の列を読んでいるとそこまでで読み上げをやめてしまう。
- さらに下の行を読むにはVoiceOverキーを押さずに下矢印キーを押してスクロールを発生させてやらなければならない
- この挙動は仮想スクロールを使用せずに大きなテーブルを設置した場合でも同じ
- Safariでは、上記に加えてさらに大きな問題が発生することがある
- ヘッダー列を読んでいくと、そこで「行の終わり」と言って、それ以上カーソルが進まなくなることがある
- しかしそういう場合に、列の冒頭に戻って高速でVoiceOver+右矢印を押していると2行目以降に行けることがある
- セルを下へ下へと移動していくと、見えている範囲ギリギリの場所より手前で「列の末尾」と言ったり、1列目を1行目から読み始めたりする
- あるいは突然10行ほど飛ばされたりするうえ、(これもオーバースキャン起因?)上方向に戻ろうとするとさらに変なセルに飛ばされたりする。
- マジでSafariとVoiceOverの挙動わからん。何これ?
仮想スクロールするtableのスクリーンリーダー対策
ここまで見てきたように、仮想スクロールのあるテーブルは、スクリーンリーダーにとっていろいろと問題が起きやすいことがわかりましたす。
- テーブル内でのカーソル移動が、製作者の意図通りに機能しない(ナレーター + Edge)
- テーブルを読もうとしたとき、真ん中あたりまで勝手にスクロールしてしまう(ナレーター + Edge, NVDA + Edge, Chrome)
- 仮想スクロールしているテーブル内での位置がわからない(PC-Talker)
- 予期できない挙動がたくさん発生する (Safari)
キーボード操作を実装する
今回やってみて、残念ながら「スクリーンリーダー側の実装は頼りにならないな」という感想を抱きました。Webブラウザと違って、スクリーンリーダーの挙動はそれぞれの開発者に委ねられているため、「こうすればどんなスクリーンリーダーでも自然に使える」という状況を作るのは無理そうです。
Webアプリケーション開発者にできることがあるとすれば、スクリーンリーダーが実装するような表内のキーボード操作や情報の読み取りを、アプリケーション側で実装することです。今回作成した「日本の郵便番号」の場合、テーブル内のセルにフォーカスしている間、矢印キーやVim風キーバインドで上下左右のセルや、最初や最後のセルに移動できるキーボードショートカットを用意しています。詳細は、「ヘルプ」ボタンをクリックすると見ることができます。
これは単にフォーカスを移動させているだけですが、スクリーンリーダーはフォーカスを移動するとスクリーンリーダーのカーソルも移動することが多いため、開発者の意図通りの操作で読めるようになります。
VoiceOverの場合は、セルにフォーカスを移したときに見出しセルを読み上げたりしてくれなかったので、aria-describedby
に、列と行の見出しセルの id
が入るようにしてあります
<div role="rowgroup">
...
<div
role="columnheader"
aria-colindex="3"
id="city"
>市区町村</div>
...
</div>
<div role="row" aria-rowindex="7">
<div aria-colindex="1" role="rowheader" id="0600031">060-0031</div>
...
<div
role="cell"
aria-colindex="3"
aria-describedby="city 0600031"
tabindex="0"
>北一条東</div>
...
こういった機能を自然に使ってもらうために、説明を置いたり、スキップリンク等で誘導するのもアリでしょう。
(ただし今回、PC-Talkerに対しては、キー入力をスクリーンリーダーが受け取らずに直接Webアプリケーションに渡す方法がわからなかったので、動作確認をできずにいます)
別の箇所で情報を補う
今回、PC-Talkerでは、aria-rowcount
aria-colcount
によりテーブルの大きさを伝えることができないことがわかりました。PC-Talkerのユーザーは、まさか12万行もあるなんて思わないまま郵便番号テーブルの間を彷徨ってしまうかもしれません。
せめてできることとしては、そのテーブルにどれくらいの大きさの情報が含まれているのかを、テーブル以外の場所で伝えてあげることだと思います。
今回の日本の郵便番号サイトでは、検索欄の横に該当する件数を表示しています。もしかしたらもうちょっとテーブルの近くにあると良いのかもしれません。
仮想スクロールをやめる
前提から覆すような話ですが、Webアプリケーションでひとつの画面に12万件もデータを表示する必要のある機会はそうそう無いんじゃないかと思います。いまどきのPCであれば数百件行くらいのテーブルであれば仮想スクロールなしでも問題なく表示することができるはずです。必然性がないなら仮想スクロールをやめてみるのもひとつの手段です。
仮想スクロールは今回扱った問題のほかにも、ページ内検索ができなくなるという問題もあります。今回の日本の郵便番号サイトでは、Ctrl+F(Command+F)で検索フィールドにフォーカスを移し、容易に絞り込みができるようにすることで誤魔化しています。これはいきなり全件の入ったJSONファイルをfetchさせているからできているのですが、これによってページ内検索をしたくなるような新機能(たとえば、市区町村の情報が見られるようになるとか)は諦めざるを得なくなっています。そういった面も含めて、本当に必要なのかを疑いつつ、必要な場合は今回紹介したようなアクセシビリティを確保する工夫をして実現していくのがよさそうです。
Discussion