GODRICH

テーブル UI デザイン 9選 — 列幅ドラッグと範囲選択まで

テーブル ui デザインとは、行と列に並べた値を読む人が自分の手で並べ替え、絞り込み、取り出せるようにする作りのことです。9つとも実際の配列を並べ替え、実際の文字列を比べ、合計は見るたびにその場で足し直します。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は作る側の都合ではなく、読む人がつまずく順番です。まず行が多すぎて目当ての値が見つからないので順序を変え(01)、文字が見切れるので列を広げ(02)、よく見る列を左へ引き寄せます(03)。それでも横にはみ出すなら、読まない列はたたんでしまいます(04)。ここまでが表の形をいじる仕事です。次の2つは値を取り出す仕事で、必要なマスだけ囲んで持ち出し(05)、条件を掛けて行数を減らします(06)。最後の3つは表が人に話しかける側です。数字の大小を読む前に色で知らせ(07)、どこまでスクロールしても合計は足元に残り(08)、マウスがなくてもマスの間を歩けます(09)。

012列を同時につかむ優先順位ソート

スコアが同じ行が2つ並ぶと、1列だけでは順序が決まりません。Shift を押しながら別のヘッダーを押すとその列が補助の基準として加わり、ヘッダーに 1 と 2 の順番バッジが付いてどちらが先に読まれるかが見えます。比較関数はキーの配列を前から辿り、値が分かれた最初のキーでそのまま結果を返します。

aria-sorttranslateYrotate
function msSort(key, add) {
  var was = {};
  [].forEach.call(msBody.children, function (el) { was[el.dataset.id] = el.getBoundingClientRect().top; });
  var hit = null;
  msKeys.forEach(function (k) { if (k.k === key) hit = k; });
  if (hit) hit.dir = -hit.dir;
  else msKeys = add ? msKeys.concat([{ k: key, dir: 1 }]).slice(-2) : [{ k: key, dir: -1 }];
  MS.sort(function (a, b) {
    for (var i = 0; i < msKeys.length; i++) {
      var k = msKeys[i];
      if (a[k.k] < b[k.k]) return -k.dir;
      if (a[k.k] > b[k.k]) return k.dir;
    }
    return 0;
  });
  msRender(); msMark();
}

02ヘッダーの境目を引いて広げる列幅

列の幅を決める値は1つでなければいけません。この部品は長さ型のカスタムプロパティを1つだけ置き、すべての行がその値を受け取ります。引いている間のガイド線も同じ値を読むので、幅を2か所に書いた途端に線とマスの境目がずれます。

grid-template-columnspointermovetranslateX
@property --w1 {
  syntax: "<length>";
  inherits: true;
  initial-value: 132px;
}
.cr__row {
  display: grid;
  grid-template-columns: var(--w1) 12px 1fr;
}
@keyframes cr-w {
  0%   { --w1: 132px; }
  8%   { --w1: 132px; }
  34%  { --w1: 180px; }
  46%  { --w1: 180px; }
  72%  { --w1: 100px; }
  86%  { --w1: 132px; }
  100% { --w1: 132px; }
}

03見出しを持ち上げて移す列の並べ替え

表を行で組むと、列を1本まるごと動かすのに全行をなぞる羽目になります。そこでこの部品は列ごとに縦のかたまりを作って横に並べ、つかんだかたまりだけを 16px 持ち上げて背景を一段明るくします。間に挟まれた列は向きに応じて列幅ぶん脇へ寄り、置かれる場所には破線の溝が残ります。

pointerdowntranslateXbox-shadow
function rdMove(e) {
  var dx = e.clientX - rdAt;
  rdEl.style.transform = 'translateX(' + dx + 'px)';
  rdTo = Math.max(0, Math.min(rdList().length - 1, rdFrom + Math.round(dx / rdW)));
  rdList().forEach(function (el, i) {
    if (el === rdEl) return;
    var shift = 0;
    if (rdTo > rdFrom && i > rdFrom && i <= rdTo) shift = -rdW;
    if (rdTo < rdFrom && i >= rdTo && i < rdFrom) shift = rdW;
    el.style.transform = 'translateX(' + shift + 'px)';
  });
}

04チェックを外すと列が消えるメニュー

隠す列を display: none で消すと、残ったマスが勝手な幅に広がります。この部品は代わりに行のトラックを 1fr 1fr 0fr に書き換えてそのマスだけを 0 にたたみ、セルには min-width: 0 とはみ出しの切り落としを指定して、文字が漏れないようにしています。最後の1列だけは消せないように止めてあります。

aria-expandedpadding-bottomsteps(1, end)
function cvPaint() {
  var live = 0, track = [];
  ['1', '2', '3'].forEach(function (k) { if (cvOn[k]) live++; track.push(cvOn[k] ? '1fr' : '0fr'); });
  [].forEach.call(cvGrid.querySelectorAll('[data-col]'), function (el) {
    el.classList.toggle('is-off', !cvOn[el.dataset.col]);
  });
  cv.style.setProperty('--t', track.join(' '));
  cvBtn.querySelector('.cv__n3').textContent = String(live);
}

05セルを引いて四角に囲み、まとめてコピー

押したマスが基準点、いま指しているマスが反対側の角なので、範囲は2点の行番号・列番号の最小と最大だけで決まります。選択枠は4辺を別々に計算せず、マス1つ分の要素を scale(横のマス数, 縦のマス数) で伸ばしています。手を離すと行は改行、列はタブでつないでクリップボードへ渡します。

pointermovescaleclipboard
let count = 0;
let sum = 0;
data.forEach((row, r) =>
  row.forEach((n, c) => {
    if (inside(r, c)) {
      count += 1;
      sum += n;
    }
  })
);
const fmt = (n: number, s: number) => `${n} ${n === 1 ? cellWord : cellsWord} · ${sumWord} ${s}`;
const boxStyle: CSSProperties = sel
  ? {
      opacity: 1,
      transform: `translate(${sel.c0 * CELL_W}px, ${(sel.r0 + 1) * CELL_H}px) scale(${
        sel.c1 - sel.c0 + 1
      }, ${sel.r1 - sel.r0 + 1})`,
    }
  : {};

06列ごとに分けて絞り込むフィルター錠剤

条件が2つ以上かかると、人はすぐになぜ行が減ったのかを忘れます。そこで効いている条件を表の上に錠剤として積み上げ、外れた行は消さずに高さだけ 0 にたたみます。行の区切り線を border ではなく内側の影で描いているのも同じ理由で、border は高さが 0 でも 1px の線として残ってしまうからです。

toLowerCasetranslateYsteps(1, end)
const keeps = (row: string[]) =>
  q.every((v, c) => {
    const t = v.trim().toLowerCase();
    return !t || row[c].toLowerCase().indexOf(t) >= 0;
  });

const onType = (c: number, value: string) => {
  setLive(false);
  setQ(q.map((v, k) => (k === c ? value : v)));
};

const chipText = (c: number, v: string) => `${head[c]} ${v}`;

07数字の大きさどおりに塗られるセル色とバー

背景色・バーの長さ・言葉のバッジを別々に判断すると、3つは必ず食い違います。ここでは区間表を一度だけ書き、値を渡すと等級を返す関数1つが3つすべてを決めます。等級はセルの属性に書き込まれ、スタイルシートはその属性だけを見ます。

scaleXsteps(1, end)data-level
const grade = (v: number) => (v >= 80 ? 3 : v >= 60 ? 2 : 1);
const word = (level: number) => (level === 3 ? bestWord : level === 2 ? goodWord : watchWord);
const barVars = (v: number): BarVars => ({ "--v": String(v / 100) });

const bump = (i: number) => {
  setLive(false);
  setData((prev) =>
    prev.map((r, n) => (n === i ? { name: r.name, value: r.value >= 95 ? 20 : r.value + 15 } : r))
  );
};

08足元に張り付いて計算し直す合計行

合計行はスクロール箱の内側に置き、position: sticky で足元に留めます。箱の外に置くとただ下に並んだ行でしかなく、スクロールとは何の関係もありません。残りのスクロール量を測って 2px より大きければその行の上に上向きの影を付け、端まで届いたら消します。

position: stickybox-shadowtranslateY
function smSum() {
  var picked = smRows().filter(function (r) { return r.classList.contains('is-pick'); });
  var list = picked.length ? picked : smRows();
  smAll.textContent = String(list.reduce(function (n, r) { return n + (+r.dataset.v); }, 0));
  document.getElementById('sm-sum').classList.toggle('is-part', picked.length > 0);
}
function smShade() {
  var left = smView.scrollHeight - smView.scrollTop - smView.clientHeight;
  sm.classList.toggle('is-more', left > 2);
}
smView.addEventListener('scroll', smShade);

09矢印でマスを渡り歩くフォーカス枠

9つのマスを全部タブで拾わせると、表1つがキーボード利用者の足を9回止めます。ここではいまいるマスだけがタブの止まり場になり、残りは順路から外れ、マス間の移動は矢印キーが受け持ちます。見える枠もマスごとに表示を切り替えず、要素1つを動かして滑らせています。

role="grid"tabindextranslate
function kgGo(r, c) {
  kgAt = { r: Math.max(0, Math.min(2, r)), c: Math.max(0, Math.min(2, c)) };
  var i = kgAt.r * 3 + kgAt.c;
  kgCells().forEach(function (el, n) { el.tabIndex = n === i ? 0 : -1; });
  kgRing.style.transform = 'translate(' + (kgAt.c * KG_W) + 'px, ' + ((kgAt.r + 1) * KG_H) + 'px)';
  kgRing.style.opacity = '1';
  kgCells()[i].focus({ preventScroll: true });
}
kgGrid.addEventListener('keydown', function (e) {
  var K = { ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0] }[e.key];
  if (K) { e.preventDefault(); kgGo(kgAt.r + K[0], kgAt.c + K[1]); return; }
  if (e.key === 'Home') { e.preventDefault(); kgGo(kgAt.r, 0); }
  if (e.key === 'End') { e.preventDefault(); kgGo(kgAt.r, 2); }
});

どこで壊れるか — 落とし穴

表でいちばんよく壊れるのは、位置を入れ替える2行がすれ違う瞬間です。01 で同点の2組が同じ瞬間に入れ替わると、その途中に4行が2行に見えるコマができます。どちらの行も背景が不透明なので、上に描かれたほうが下を完全に覆ってしまうからです。24枚で作るプレビューでは、よりによってそのコマが最も大きく変わった1枚となり、代表画像に選ばれていました。直し方は、2組の移動時刻を 20% ずらし、上へ向かう行にだけ影と重なり順を与えて、どちらがすれ違っているかを残すことでした。

2つ目は逆側です。出す動きと消す動きを同じ強さにすると、消すほうが大きく変わります。04 でメニューを開くときに現れるのはメニューだけですが、閉じるときはメニューが消えると同時に、その裏に隠れていた表まで一度に現れ直すからです。05 も同じでした。9つのマスを一度に消したので、何も選ばれていない空の表が最も変化した1枚になりました。直し方はどちらも同じで、出す側はそのままにして消す側だけを散らします。04 では内側の入れ物を先に薄くしてから外側だけを切って消し、05 では四角が 3×3 から 2×2、1×1 へ縮むのと同じ瞬間に下の帯の文言も一段ずつ下げました。文言だけ遅れて消すと、2×2 の四角の横に「9マス」と書かれた嘘のコマが残ります。

3つ目は列の幅を2か所に書くことです。02 の最初の版はマスの幅とガイド線の位置を別々の値で持っていて、速く引くたびに線が境目より数 px 先に出たり遅れたりしました。同じコマに描かれているのにです。値を1つのカスタムプロパティにまとめ、ガイド線もそこから受け取るようにしたらずれは消えました。加えてこのカスタムプロパティは @property で長さ型として登録しておく必要があります。登録しないとブラウザは値をただの文字として扱うので、途中の値を作らず一度に飛びます。

最後は狭い画面です。これらの部品は記事の中に 480×300 で入り、スマートフォンでは 320×200 に縮みます。ステージの余白を引くと縦に使えるのは 174px だけですが、320px 幅で9つの高さを測ったところ 108px から 164px の間でした。ヘッダーの下にフィルター行と錠剤の行が重なる 06 が 164px でいちばん高く、余りは 10px でした。ですから狭い画面では間隔を広げずに狭めます。列の幅を詰め、セルの左右の余白を 8px から 4px に下げただけで、縦の間隔には触れていません。ここを取り違えて、狭い画面用のメディアクエリの中で 2px や 3px の半端な余白を 4px に切り上げると、1項目あたり数 px しか増えていなくても 174px の枠はあっさり越えます。丸めて揃えたいなら広い画面の側だけにして、狭い画面では 0 に落として帳尻を合わせるほうが安全です。04 のように絶対配置で開く一覧を持つ部品はさらに注意が要ります。開いた一覧はレイアウトの高さには数えられないのに、はみ出しの判定に使われる値にはそのまま乗ってくるので、開いた分の高さを親の下余白としてあらかじめ確保しておかないと、閉じているあいだは何ともないのに開いた瞬間だけスクロールバーが出ます。9組の規則を画面ごとに書き分けると管理しきれなくなるので1つのフォルダにまとめてあり、その圧縮ファイルのパスワードは g445uude で、中にはいま見ているバニラ版と React 版が同じ値で入っています。

アクセシビリティ

prefers-reduced-motion: reduce を有効にした画面では、9つとも繰り返しループが止まり、結果の状態だけが残ります。ソート方向の印は回らずにその向きで立ち、バーは伸びる過程なしにその長さで置かれ、渡り歩く枠は滑らなくてもどのマスにいるかは見えたままです。動きが消えても「この列で並べ替えている」という事実まで消えてはいけないからです。

表は骨組みそのものがアクセシビリティを左右します。9つとも外側は role="grid" で、行とマスには role="row"role="gridcell"、見出しには role="columnheader" を付けています。見出しがボタンの 01 と、列単位で組んだ 03 だけは構造に合わせて付け方を変えています。色だけで伝えないのも同じ理由で、07 は背景の濃淡の横に等級の言葉を添え、08 は影ではなく合計の数字そのものが変わって状態を知らせます。

項目 目で スクリーンリーダーで 操作
01 優先順位ソート 三角の向き + 順番バッジ aria-sort を ascending・descending で切り替え クリック・Shift クリック
02 列幅 ガイド線とマスの幅 role="separator" に名前を付与 ドラッグ・左右矢印で 8px
03 列の並べ替え 浮いた列 + 破線の溝 見出しの順序が文書上でも入れ替わる ポインタードラッグ
04 列の表示切替 表からたたまれるマス aria-expandedrole="checkbox" クリック
05 範囲選択 青い四角 + マスの色 下の帯がマス数と合計を文で伝える ドラッグして離す
06 列フィルター たたまれる行 + 錠剤 入力欄ごとの aria-label 1文字ごと
07 条件付き書式 濃淡・バー・バッジ 等級の言葉がセルの横に文字で並ぶ セルのクリック
08 合計行 足元固定 + 影 合計の数字が部分合計に変わる スクロール・行のクリック
09 キーボード移動 太い枠 実際にそのマスへフォーカスが移る 矢印・Home・End

格子状の表にどの役割と状態を付けるべきかは MDN の grid ロールの解説にまとまっています。同じ系統のリスト・表の部品はダッシュボードのカテゴリに、JavaScript が実際に動く部品はJS のカテゴリに集めてあります。

FAQ

並べ替えは CSS だけでできませんか

行の順序を変えるところまではできます。各行に order を与えて親を flex にすれば描く順番は変わります。できないのはその値を決めることで、4つのスコアに1位から4位まで番号を振るのは比較ですから、結局その計算は JavaScript が担います。もっと厄介な点もあります。order で変えた順序は描画だけの話で、スクリーンリーダーは文書の順序を読むので、目で見た1位と耳で聞いた1位が食い違います。01 が実際の配列を並べ替えて描き直しているのはそのためです。

列の幅をピクセルで保存すると画面サイズが変わったときに壊れませんか

壊れます。だから 02 は幅に 88px の下限と 200px の上限を置き、その間にしか収まらないようにしています。保存まで考えるなら、ピクセル数ではなく表全体の幅に対する割合で持つほうが安全です。割合なら画面の大きさが変わっても使い回せるからです。ただし割合は最小の読みやすさを保証してくれないので、最低幅だけはピクセルで別に決めておくとよいです。そうしないと小さな画面で1マスが1文字より狭くなります。

行が1,000行を超えても、このまま使えますか

06 のフィルターは全行をなぞるので、行数に比例してそのまま遅くなります。数百行なら何ともありませんが、それを超えると画面に映っている行だけを描く方式に切り替えたくなります。08 がスクロール箱を別に持つ構造がその出発点です。箱の高さと行の高さが分かれば、いま先頭に来ている行の番号は計算できるので、その範囲だけを描けば済みます。気を付ける点は1つだけで、合計は描いた行ではなく元の配列全体から足し直さなければいけません。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。