ソート可能テーブル
列ヘッダーをタップすると昇順/降順に並び替わり、矢印で現在の方向を表示する。日本語ダミーデータ5行。
プレビュー
操作クリック/タップで動きます。
仕様書
- 向く場面
- 在庫・ランキング・注文一覧など並べ替えが主目的の表
- 数行〜数十行の小さなデータ
- 最大・最小を素早く探させたい一覧
- 避ける場面
- 数百行を超えるデータ(絞り込みが要る)
- 複数条件のフィルタが主目的の一覧
- 構成要素
- 列見出し
- 並び順の矢印
- データ行
- ゼブラ背景
- 持っている状態
- 通常
- ホバー
- フォーカス
- 押下中
- キーボード
Tab で各列見出しの中の button へ、Enter/Space で昇順→降順を切替
- 支援技術
table に aria-label。th の中を button にして「〜で並べ替え」の名前を持たせ、aria-sort は並べ替え中の th だけに付ける。未ソート列は薄い ↕ で並べ替え可能を示す。矢印は CSS で生成。reduced-motion で遷移停止
- 動き
見出しの背景 150ms。並べ替え自体はアニメ無し
デザインの要点
- aria-sort は並べ替え中の 1 列にだけ付け、矢印はその属性から CSS で生成する。JS は属性を切り替えるだけ
- 未ソートの列にも薄い ↕ を出す。何も無いと並べ替えられる表だと気づかれない
- 見出しは th の中を button にする。th 自体に tabindex を付けても「押せる」ことが名前から伝わらない
- 行の再描画は tr を使い回して textContent だけ差し替える。5 行でも数百行でも同じ関数で済む
使いどころ
用途は絞り込みより並べ替えが主目的の一覧が対象。在庫リスト、ランキング、注文一覧など、数値やテキストの大小・五十音で並べ替えると「一番高い/一番安い」を探しやすくなる場面に向く。逆に、行数が数百件を超えるデータや、複数条件でのフィルタリングが必要な一覧には力不足で、ソートだけでは目的の行にたどり着けない。デモは3列・5行だが、実運用では列数が増えるほど「今どの列でソートしているか」が見えにくくなるため、アクティブな列のヘッダーを背景色や太字で常時強調しておくと迷わない。table-layout: fixedで列幅を固定しているため、商品名のような可変長テキストを扱う列は幅の見積もりを先に決めておく必要があり、幅が狭いレイアウトでは横スクロールできるラッパーで包む前提で設計するとよい。
仕組み
各th要素にdata-key(ソートに使うプロパティ名)とdata-type(str/num)を持たせ、クリックまたはEnter/SpaceキーでsortByを呼ぶ。同じ列を再度押すと昇順⇄降順をトグルし、別の列を押すと新規に昇順から始まる。矢印記号は直接書き換えず、st-th[aria-sort="ascending"] .st-arrow::after { content: " ↑"; } のようにaria-sort属性依存のCSS ::after contentで描画しており、JSはDOM属性を切り替えるだけでよい設計になっている。行の再描画はrenderRows()が既存の<tr>要素を可能な限り再利用し、textContentだけを差し替える方式で、DOM生成コストを抑えている。文字列比較はlocaleCompare(bv, "ja")を使っており、単純な文字コード比較よりも日本語の五十音順に近い並びになる。
実装の注意
初期表示で矢印が一切無いと「この表は並べ替えできる」ことが視覚的に伝わらないので、未ソートの列には薄い上下矢印(↕)を常時出しておく(なおaria-sortを初期状態から全列に付ける必要はない——後述のとおり既定値がnoneのため)。次に、thをtabindex="0"でフォーカス可能にするだけの実装だとrole="button"や適切な意味づけが無く、支援技術には単なる見出しセルとして扱われて「押せる」ことがラベルから伝わらない。この見本はth内に<button>を置く定石にしてある。button化するとEnter/Spaceの既定動作をブラウザに任せられ、自前のkeydownでSpace時のe.preventDefault()を呼び忘れてページスクロールが同時に起きる事故も無くなる。ソート中のアニメーションが一切ないため、行数が多いテーブルを切り替えると視線が追いにくいのも実務では気になりやすい。
アクセシビリティ
aria-sortの扱いはこの実装が正しい。属性の既定値はnoneなので未ソートのthに明示する必要はなく、WAI-ARIAはむしろ「一度に1つのヘッダーにだけ適用する」ことを推奨している——全thにaria-sort="none"を並べると「どの列が今の並び順か」という一意性の意味が薄れるため、現在の「能動列にだけ付け、他からは外す」動きを崩さないこと。クリック可能なthは見た目のcursor: pointerだけでなく、th内に<button>を入れてボタン化するのが定石で、そうすれば支援技術がクリック可能と自動的に認識し、フォーカスリングやEnter/Spaceの既定動作もブラウザに任せられる。この見本のbuttonは「商品で並べ替え」のようにaria-labelで何の列かを名乗り、aria-sortはth側に置く(buttonに付けても無効)。並べ替え結果(何位が何位に動いたか)は視覚的にしか伝わらないため、tbodyにaria-live="polite"を足して件数や先頭行の変化を読み上げさせる改善余地もある。
調整して使う
触れる点は主に3つ。ヘッダーの色(既定は#2b50c7の藍、hoverは#1a3aaa)はブランドカラーに応じて変えられ、hoverで一段暗くする関係を保てばクリック可能性が伝わりやすい。次に矢印の見せ方で、::afterのテキスト矢印(" ↑"/" ↓")の代わりにSVGアイコンへ差し替えると、フォントによる太さの不揃いを避けられる。最後にソート対象の型で、デモはstr/numの2種類だが、日付列を扱うにはdata-type="date"を足してnew Date()での比較分岐を加えるだけで拡張できる。行の背景を偶数/奇数で塗り分けるゼブラストライプ(nth-child(even)/(odd))は既に入っており、行数が増える運用ほどこの効果が効いてくる。
コード(コピーして使えます)
<div class="st-wrap">
<table class="st-table" aria-label="売上テーブル">
<thead>
<tr>
<th class="st-th" data-key="name" data-type="str" scope="col">
<button class="st-sort" type="button" aria-label="商品で並べ替え">商品<span class="st-arrow" aria-hidden="true"></span></button>
</th>
<th class="st-th" data-key="sales" data-type="num" scope="col">
<button class="st-sort" type="button" aria-label="売上で並べ替え">売上<span class="st-arrow" aria-hidden="true"></span></button>
</th>
<th class="st-th" data-key="rank" data-type="num" scope="col">
<button class="st-sort" type="button" aria-label="順位で並べ替え">順位<span class="st-arrow" aria-hidden="true"></span></button>
</th>
</tr>
</thead>
<tbody id="stBody"></tbody>
</table>
</div>