Reactで、表の列の幅をドラッグで変える方法

​​React​CSS,Playwright,Pointer Events,TypeScript,アクセシビリティ

表の列の幅をドラッグで変えるには、列ごとの幅を state に持ち、<colgroup> の <col> に渡します。このとき、表に table-layout: fixed を付け、表全体の幅を列の幅の合計にしておくことが欠かせません。どちらかが欠けると、state の幅と、実際に描かれる列の幅がずれました。

作るのは、ライブラリを使わない ResizableTable コンポーネントです。見出しのセルの右端に取っ手を置き、ドラッグすると列の幅が変わります。取っ手のダブルクリックで中身に合う幅にし、キーボードの左右の矢印でも幅を変えられるようにします。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 です。マウスとキーボードは Playwright で操作し、タッチは Chromium の DevTools プロトコル(CDP)の Input.dispatchTouchEvent で、スマートフォンの表示を模した画面に送りました。実機のスマートフォン、Firefox と Safari、スクリーンリーダーでの読み上げは試していません。列の並べ替えや、決めた幅の保存は扱いません。

1回目は商品名の列の取っ手を右へドラッグして幅が 140px から 240px へ広がり、2回目は左へドラッグして 60px で止まり、すべての商品名の末尾が「…」になる。3回目は取っ手をダブルクリックして、いちばん長い商品名が収まる 233px になる。幅が変わったコマだけ数字がピンクになる
商品名の列の幅を変える3回の操作(丸はマウスの位置で、押している間とダブルクリックの瞬間は濃くなる。丸と上の表示は GIF 用に重ねたもの)

ResizableTable コンポーネントの全体

先にコード全体を載せます。ドラッグは Pointer Events で受け取ります。

tsx
import { useRef, useState } from 'react';
import type { KeyboardEvent, PointerEvent } from 'react';

export type Column<T> = {
  key: keyof T & string;
  label: string;
  width: number; // 最初の幅(px)
};

const MIN_WIDTH = 60;
const MAX_WIDTH = 600;
const KEY_STEP = 10;

function clamp(n: number, lo: number, hi: number) {
  return Math.min(hi, Math.max(lo, n));
}

type Drag = {
  key: string;
  pointerId: number;
  startX: number; // 押したときのポインターの x 座標
  startWidth: number; // 押したときの列の幅
};

type Props<T> = {
  columns: Column<T>[];
  rows: T[];
  getRowKey: (row: T) => string;
};

export function ResizableTable<T>({ columns, rows, getRowKey }: Props<T>) {
  const [widths, setWidths] = useState<Record<string, number>>(() =>
    Object.fromEntries(columns.map((c) => [c.key, c.width])),
  );
  const drag = useRef<Drag | null>(null);
  const tableRef = useRef<HTMLTableElement>(null);

  function setWidth(key: string, width: number) {
    setWidths((prev) => ({ ...prev, [key]: clamp(Math.round(width), MIN_WIDTH, MAX_WIDTH) }));
  }

  function handlePointerDown(e: PointerEvent<HTMLDivElement>, key: string) {
    if (e.pointerType === 'mouse' && e.button !== 0) return;
    e.preventDefault(); // ドラッグ中に文字が選ばれないようにする
    // 取っ手の外へ出ても、離すまで取っ手にイベントが届くようにする
    e.currentTarget.setPointerCapture(e.pointerId);
    drag.current = { key, pointerId: e.pointerId, startX: e.clientX, startWidth: widths[key] };
  }

  function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
    const d = drag.current;
    if (!d || d.pointerId !== e.pointerId) return;
    setWidth(d.key, d.startWidth + (e.clientX - d.startX));
  }

  function handlePointerEnd(e: PointerEvent<HTMLDivElement>) {
    if (drag.current?.pointerId === e.pointerId) drag.current = null;
  }

  // ダブルクリックで、その列のいちばん長い中身が収まる幅にする
  function fitToContent(key: string) {
    const cells = tableRef.current?.querySelectorAll<HTMLElement>(`[data-col="${key}"]`) ?? [];
    let max = 0;
    for (const cell of cells) {
      const text = cell.querySelector<HTMLElement>('.cell-text');
      if (!text) continue;
      const style = getComputedStyle(cell);
      const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
      max = Math.max(max, text.getBoundingClientRect().width + padding);
    }
    if (max > 0) setWidth(key, Math.ceil(max));
  }

  function handleKeyDown(e: KeyboardEvent<HTMLDivElement>, key: string) {
    const delta = e.key === 'ArrowRight' ? KEY_STEP : e.key === 'ArrowLeft' ? -KEY_STEP : 0;
    if (delta === 0) return;
    e.preventDefault();
    setWidth(key, widths[key] + delta);
  }

  const totalWidth = columns.reduce((sum, c) => sum + widths[c.key], 0);

  return (
    <div className="table-wrap">
      <table ref={tableRef} className="resizable-table" style={{ width: totalWidth }}>
        <colgroup>
          {columns.map((c) => (
            <col key={c.key} style={{ width: widths[c.key] }} />
          ))}
        </colgroup>
        <thead>
          <tr>
            {columns.map((c) => (
              <th key={c.key} scope="col" data-col={c.key}>
                <span className="cell-text">{c.label}</span>
                <div
                  className="col-resizer"
                  role="separator"
                  aria-orientation="vertical"
                  aria-label={`${c.label}の列の幅`}
                  aria-valuemin={MIN_WIDTH}
                  aria-valuemax={MAX_WIDTH}
                  aria-valuenow={widths[c.key]}
                  tabIndex={0}
                  onPointerDown={(e) => handlePointerDown(e, c.key)}
                  onPointerMove={handlePointerMove}
                  onPointerUp={handlePointerEnd}
                  onPointerCancel={handlePointerEnd}
                  onDoubleClick={() => fitToContent(c.key)}
                  onKeyDown={(e) => handleKeyDown(e, c.key)}
                />
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {rows.map((row) => (
            <tr key={getRowKey(row)}>
              {columns.map((c) => (
                <td key={c.key} data-col={c.key}>
                  <span className="cell-text">{String(row[c.key])}</span>
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

使う側では、列の定義と行のデータを渡します。width は最初の幅です。

tsx
import { ResizableTable } from './ResizableTable';
import type { Column } from './ResizableTable';

type Item = { id: string; name: string; category: string; price: number; note: string };

const columns: Column<Item>[] = [
  { key: 'name', label: '商品名', width: 140 },
  { key: 'category', label: '分類', width: 90 },
  { key: 'price', label: '価格(円)', width: 110 },
  { key: 'note', label: '説明', width: 200 },
];

const rows: Item[] = [
  { id: '1', name: 'ステンレスの保温マグカップ', category: '食器', price: 2480, note: 'ふた付きで、飲み物が冷めにくいマグカップです。' },
  { id: '2', name: '木のまな板', category: '調理器具', price: 1980, note: '片面に溝があり、肉汁がこぼれにくくなっています。' },
  { id: '3', name: '綿のふきん(5枚組)', category: '日用品', price: 880, note: '吸水性が高く、乾きやすい薄手の生地です。' },
];

export function App() {
  return (
    <main>
      <ResizableTable columns={columns} rows={rows} getRowKey={(r) => r.id} />
    </main>
  );
}

CSS は次のとおりです。

css
.table-wrap {
  max-width: 100%;
  overflow-x: auto; /* 表が画面より広くなったら、横にスクロールさせる */
}
.resizable-table {
  table-layout: fixed; /* 列の幅を、中身ではなく col の指定で決める */
  border-collapse: separate;
  border-spacing: 0;
}
.resizable-table th,
.resizable-table td {
  position: relative; /* 取っ手の位置の基準にする */
  padding: 8px 12px;
  border-bottom: 1px solid #d5dde1;
  text-align: left;
  /* 入りきらない文字は1行のまま隠し、末尾を「…」にする */
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.resizable-table th {
  background: #eefafd;
}
.col-resizer {
  position: absolute;
  top: 0;
  right: 0;
  width: 8px;
  height: 100%;
  cursor: col-resize;
  touch-action: none; /* 指でなぞっても、ページがスクロールしないようにする */
}
.col-resizer::after {
  content: '';
  position: absolute;
  top: 20%;
  right: 3px;
  width: 2px;
  height: 60%;
  background: #9aa5ab;
}
.col-resizer:hover::after,
.col-resizer:focus-visible::after {
  background: #00c8e8;
}

ドラッグした距離を、押したときの幅に足す

取っ手を押したとき(pointerdown)に、ポインターの x 座標と、その列の幅を drag に覚えておきます。pointermove では、押したときから動いた距離 e.clientX - d.startX を、押したときの幅に足します。幅は setWidth で 60〜600px に収め、整数に丸めてから state に入れます。

商品名の列(最初は 140px)の取っ手を動かし、state の幅と、実際に描かれた見出しのセルの幅を比べました。

text
左へ 300px(離さない): 商品名: state 60 / 実際 60
そのまま押した位置へ戻して離す: 商品名: state 140 / 実際 140
右へ 80px: 商品名: state 220 / 実際 220
左へ 60px(途中で下へ 120px 外れる): 商品名: state 160 / 実際 160
左へ 300px: 商品名: state 60 / 実際 60
入りきらずに隠れたセル(商品名): 4 / 4
表示されている文字(1行目の商品名のセル): ellipsis

最初の2行は、1回のドラッグの途中です。左へ 300px 動かすと最小の 60px で止まり、離さずに押した位置まで戻すと 140px に戻りました。前回の pointermove からの差を足していく書き方と違い、途中で幅が止められても、ずれが残りません。

その後の右へ 80px、左へ 60px でも、state と実際の幅は一致しました。左へ 60px 動かすときは、途中でポインターを取っ手から下へ 120px 外しましたが、幅は最後まで変わり続けました。pointerdown で setPointerCapture を呼び、離すまで取っ手に pointermove が届くようにしているためです。

60px まで狭めると、商品名の列は見出しと3行の、4つのセルすべてで文字が入りきらず、末尾が「…」になりました(上の GIF の2回目)。セルに overflow: hidden、white-space: nowrap、text-overflow: ellipsis を付けているためです。white-space: nowrap を外し、同じように左へ 300px ドラッグすると(完成版では、このドラッグで最小の 60px になります)、文字がセルの中で折り返し、1行目の商品名のセルの高さが変わりました。

text
white-space: nowrap あり: 41px
white-space: nowrap なし: 185px

取っ手は見出しのセルの右端に position: absolute で重ね、幅を 8px にしています。見た目の線は ::after で描いた 2px の線で、つかめる範囲はそれより広くしてあります。

table-layout: fixed がないと、幅が中身で決まる

表の列の幅の決め方は、CSS の table-layout で変わります。初期値の auto では、セルの中身に合わせて列の幅が決まります。fixed にすると、中身を見ずに、<col> に指定した幅で列の幅が決まります。

完成版から table-layout: fixed だけを外し、同じ幅を <col> に渡して比べました。

text
商品名: state 140 / 実際 232.015625、分類: state 90 / 実際 88.015625、価格(円): state 110 / 実際 104.015625、説明: state 200 / 実際 404.65625
商品名を左へ 80px: 商品名: state 60 / 実際 232.015625

商品名の列は、state では 140px なのに、実際は 232px でした。あとで出てくるダブルクリックで測った、いちばん長い商品名が1行に収まる幅(233px)とほぼ同じです。左へ 80px ドラッグして state を 60px にしても、実際の幅は 232px のまま変わりませんでした。ドラッグしても列が動かないので、取っ手が壊れているように見えます。

表の幅を 100% にすると、余った幅が各列に配られる

MDN の table-layout の説明には、fixed を効かせるには表の width を指定する必要があり、表の width が auto(指定なし)のままだと fixed は効かない、と書かれています。表を親の幅いっぱいに広げたくて、width: 100% を指定したくなるところです。

完成版の表の幅を、width: 100% に変えて比べました。表示領域の横幅は 800px で、main の左右の padding(16px ずつ)を除いた 768px が表の幅になります。

text
商品名: state 140 / 実際 199.109375、分類: state 90 / 実際 128、価格(円): state 110 / 実際 156.4375、説明: state 200 / 実際 284.453125
商品名を右へ 80px: 商品名: state 220 / 実際 272.515625、分類: state 90 / 実際 111.46875、価格(円): state 110 / 実際 136.25、説明: state 200 / 実際 247.765625

列の幅の合計(540px)より表の幅が広いので、余った幅が各列に足され、どの列も state の幅より広く描かれました。商品名の列を右へ 80px ドラッグすると、商品名は 199px から 272px へ広がりましたが、ほかの3つの列は狭くなりました。表の幅が 768px のまま変わらないので、広げた分をほかの列から取ったことになります。

完成版では、表の style の width に列の幅の合計 totalWidth を入れています。こうすると余りが出ないので、ある列を広げても、ほかの列の幅は変わりません。そのかわり、表全体の幅が変わります。

ポイント

列の幅を state のとおりに描かせるには、table-layout: fixed と、表の幅を列の幅の合計にすることの両方が要ります。

広くなった表は、囲みの中で横にスクロールさせる

表の幅は列の幅の合計なので、列を広げると、画面より広くなることがあります。そこで、表を <div className="table-wrap"> で囲み、overflow-x: auto を付けています。

表示領域を 360px にして、overflow-x: auto がある場合とない場合を比べました。表の幅は 540px です。

text
overflow-x: auto あり: ページの横幅 360px(表示領域 360px)/ 囲みの幅 328px、中身の幅 540px
overflow-x: auto なし: ページの横幅 556px(表示領域 360px)/ 囲みの幅 328px、中身の幅 540px

overflow-x: auto がないと、ページ全体の横幅が 556px になり、ページごと横にスクロールするようになりました。あるときは、ページの横幅は 360px のままで、横にスクロールするのは囲みの中だけです。

ダブルクリックで、中身に合う幅にする

取っ手をダブルクリックすると、fitToContent で、その列のいちばん長い中身が収まる幅にします。列のセルには data-col を付けておき、セルの中の <span className="cell-text"> の幅を getBoundingClientRect() で測ります。<span> はインラインの要素なので、セルが狭くて文字が隠れていても、文字の幅そのものが測れます。それにセルの左右の padding を足し、いちばん大きい値を新しい幅にします。

商品名の列を 60px まで狭めてからダブルクリックし、続けて説明と価格の列の取っ手もダブルクリックしました。

text
商品名: 商品名: state 233 / 実際 233
入りきらずに隠れたセル(商品名): 0 / 4
説明: 説明: state 405 / 実際 405
入りきらずに隠れたセル(説明): 0 / 4
幅を 1px 減らすと隠れるセル(説明): 1 / 4
価格(円): 価格(円): state 105 / 実際 105
入りきらずに隠れたセル(価格(円)): 0 / 4

商品名の列は 233px、説明の列は 405px になり、どちらも文字が隠れたセルは 0 になりました。5行目は、説明の列をそこから 1px だけ狭めた結果です。いちばん長い説明のセル1つが入りきらなくなったので、測った幅に余分はありません。

見出しの文字も cell-text で包み、測る対象に入れています。価格の列は、中身(3〜4桁の数字)より見出しの「価格(円)」のほうが長い列です。ダブルクリックすると 105px になり、見出しも含めて隠れたセルは 0 でした。

取っ手はキーボードでも動かせるようにする

取っ手には tabIndex={0} を付けてフォーカスできるようにし、右の矢印で 10px 広げ、左の矢印で 10px 狭めます。役割は ARIA の separator ロールで伝えます。MDN の説明では、フォーカスできて動かせる separator には aria-valuenow が必要で、aria-valuemin と aria-valuemax も既定の 0 と 100 でなければ指定するとされています。そこで、列の幅と、60 と 600 を入れました。

表に特有なのは、取っ手が列の数だけあることです。MDN では、フォーカスできる separator が複数あるときは aria-label で名前を付けるとされているので、「価格(円)の列の幅」のように、どの列の取っ手かが分かる名前にしています。価格の列の取っ手で確かめた結果です。

text
ArrowRight→120 ArrowRight→130 ArrowLeft→120 Enter→120
属性: role=separator aria-orientation=vertical aria-label=価格(円)の列の幅 aria-valuemin=60 aria-valuemax=600 tabindex=0

指でなぞるときは、取っ手に touch-action: none を付ける

取っ手の CSS の touch-action: none は、指の動きをブラウザにパンやズームとして扱わせず、すべて pointermove としてページに渡すための指定です。取っ手の touch-action だけを auto に変え、商品名の列の取っ手を指で右へ 60px なぞって比べました。

text
touch-action: none: 右へ 60px なぞる: 商品名 200 / 囲みの scrollLeft 0 / イベント: pointerdown pointerup
touch-action: auto: 右へ 60px なぞる: 商品名 158 / 囲みの scrollLeft 0 / イベント: pointerdown pointercancel

auto のときは、ブラウザが横になぞる動きを自分で扱い始め、途中で pointercancel が届いてドラッグが 158px で止まりました。none のときは最後に pointerup が届き、140px に 60px を足した 200px になりました。どちらも囲みの scrollLeft が 0 なのは、囲みがいちばん左にあり、右へなぞってもそれより左へはスクロールできないからです。

​Posted by 86