Reactで、表のセルをドラッグで範囲選択する方法

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

表のセルをドラッグして四角い範囲で選ぶには、選び始めたセルと、今ポインターがあるセルの2つだけを state に持ちます。各セルが範囲に入るかどうかは、その2つから毎回計算します。ドラッグ中のセルは、pointermove の座標から document.elementFromPoint() で探します。

同じ2つのセルを使えば、Shift を押したクリックや Shift+矢印キーで範囲を伸ばす操作も、数行で足せます。選んだ範囲を Ctrl+C でタブ区切りの文字列としてコピーする方法と、ドラッグ中にセルの文字が選択されてしまうのを防ぐ方法もあわせて確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright の page.mouse と page.keyboard で操作しました。コピーした内容は、同じページの textarea に貼り付けて確かめています。タッチ操作、ペン、Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げ、表計算ソフトへの貼り付け、スクロールする大きな表は試していません。

月ごとの果物の数の表で、1月のりんごから3月のみかんへドラッグすると、6つのセルが水色になり、下の行が「6個のセル 合計 119」になる。続けて Shift を押しながら4月のももをクリックすると、1月〜4月の行の、りんご〜ももの16個のセルが選ばれ「16個のセル 合計 200」になる。最後に Ctrl+C を押してから下の入力欄に Ctrl+V で貼ると、選んだ数字がタブと改行で区切られて入る
ドラッグ、Shift を押したクリック、コピーと貼り付けの3回の操作で、選ぶセルと下の行の数字が変わる様子。変わった瞬間だけ、その数字をピンクにしている(カーソル、押している間の輪、キーの表示、操作の説明と回数の表示は GIF 用に重ねたもの)

選ぶ範囲は「起点」と「端」の2つのセルで持つ

選んでいるセルを1つずつ配列や Set に入れると、ドラッグで端が動くたびに、増えたセルと減ったセルを数え直すことになります。四角い範囲しか選ばないなら、押したセル(anchor)と今の端のセル(focus)の2つを持てば足ります。

ts
export type Cell = { row: number; col: number };
// anchor は選び始めたセル、focus は今の端のセル
export type Range = { anchor: Cell; focus: Cell };

// anchor と focus を、上下左右の端に直す(どちら向きにドラッグしても同じ四角になる)
export function bounds({ anchor, focus }: Range) {
  return {
    top: Math.min(anchor.row, focus.row),
    bottom: Math.max(anchor.row, focus.row),
    left: Math.min(anchor.col, focus.col),
    right: Math.max(anchor.col, focus.col),
  };
}

export function inRange(range: Range | null, row: number, col: number): boolean {
  if (!range) return false;
  const b = bounds(range);
  return b.top <= row && row <= b.bottom && b.left <= col && col <= b.right;
}

// 選んだ範囲を、タブと改行で区切った文字列にする(表計算ソフトに貼れる形)
export function toTSV(data: string[][], range: Range): string {
  const b = bounds(range);
  return data
    .slice(b.top, b.bottom + 1)
    .map((row) => row.slice(b.left, b.right + 1).join('\t'))
    .join('\n');
}

bounds で Math.min と Math.max を取っているので、右下へドラッグしても左上へドラッグしても、同じ四角になります。anchor を残しておくのは、端だけを動かして範囲を伸ばすときに使うためです。この使い方は、後の Shift の節で出てきます。

表のコンポーネントの全体

コンポーネントの全体は次のとおりです。ポインターのイベントのハンドラーは、セルごとではなく、<table> にイベントの種類ごとに1つずつ付けています。

tsx
import { useRef, useState } from 'react';
import { bounds, inRange, toTSV, type Cell, type Range } from './range';

type Props = {
  label: string;
  columns: string[];
  rowHeaders: string[];
  data: string[][]; // data[行][列]
};

const keys: Record<string, [number, number]> = {
  ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1],
};

function sameCell(a: Cell, b: Cell) {
  return a.row === b.row && a.col === b.col;
}

// 画面上の位置にあるセルを探す
function cellAt(x: number, y: number, table: HTMLTableElement): Cell | null {
  const td = document.elementFromPoint(x, y)?.closest<HTMLTableCellElement>('td[data-row]');
  if (!td || !table.contains(td)) return null;
  return { row: Number(td.dataset.row), col: Number(td.dataset.col) };
}

export function CellRangeTable({ label, columns, rowHeaders, data }: Props) {
  const [range, setRange] = useState<Range | null>(null);
  const dragging = useRef(false);

  function onPointerDown(e: React.PointerEvent<HTMLTableElement>) {
    if (e.button !== 0) return;
    const cell = cellAt(e.clientX, e.clientY, e.currentTarget);
    if (!cell) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    dragging.current = true;
    // Shift を押していれば、前の起点から伸ばす
    setRange((prev) => (e.shiftKey && prev ? { anchor: prev.anchor, focus: cell } : { anchor: cell, focus: cell }));
  }

  function onPointerMove(e: React.PointerEvent<HTMLTableElement>) {
    if (!dragging.current) return;
    const cell = cellAt(e.clientX, e.clientY, e.currentTarget);
    if (!cell) return; // 表の外では、最後のセルのまま
    setRange((prev) => (prev && !sameCell(prev.focus, cell) ? { ...prev, focus: cell } : prev));
  }

  function endDrag() {
    dragging.current = false;
  }

  function onKeyDown(e: React.KeyboardEvent<HTMLTableElement>) {
    const d = keys[e.key];
    if (!d) return;
    e.preventDefault(); // 矢印キーでページがスクロールしないように
    setRange((prev) => {
      const from = prev?.focus ?? { row: 0, col: 0 };
      const next = {
        row: Math.min(data.length - 1, Math.max(0, from.row + d[0])),
        col: Math.min(columns.length - 1, Math.max(0, from.col + d[1])),
      };
      return e.shiftKey && prev ? { anchor: prev.anchor, focus: next } : { anchor: next, focus: next };
    });
  }

  function onCopy(e: React.ClipboardEvent<HTMLTableElement>) {
    if (!range) return;
    e.preventDefault();
    e.clipboardData.setData('text/plain', toTSV(data, range));
  }

  const b = range && bounds(range);
  const values = b ? data.slice(b.top, b.bottom + 1).flatMap((r) => r.slice(b.left, b.right + 1)) : [];
  const sum = values.reduce((s, v) => s + Number(v), 0);

  return (
    <div>
      <table
        className="cell-range"
        role="grid"
        aria-label={label}
        aria-multiselectable="true"
        tabIndex={0}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        onKeyDown={onKeyDown}
        onCopy={onCopy}
      >
        <thead>
          <tr>
            <th />
            {columns.map((c) => <th key={c} scope="col">{c}</th>)}
          </tr>
        </thead>
        <tbody>
          {data.map((row, r) => (
            <tr key={rowHeaders[r]}>
              <th scope="row">{rowHeaders[r]}</th>
              {row.map((v, c) => (
                <td
                  key={c}
                  data-row={r}
                  data-col={c}
                  aria-selected={inRange(range, r, c)}
                  className={range && sameCell(range.focus, { row: r, col: c }) ? 'focus' : undefined}
                >
                  {v}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
      <p className="status" aria-live="polite">
        {values.length > 0 ? `${values.length}個のセル 合計 ${sum}` : 'セルが選ばれていません'}
      </p>
    </div>
  );
}

セルには data-row と data-col で、何行目・何列目かを持たせています。見出しの th には付けていないので、見出しを押しても cellAt が null を返し、選択は始まりません。検証環境で列の見出しの「りんご」をクリックしても、選んだセルは0のままでした。表の下の行には、選んだセルの数と合計を出しています。

使う側では、表の名前と列・行の見出し、データを渡します。

tsx
const columns = ['りんご', 'みかん', 'ぶどう', 'もも'];
const rowHeaders = ['1月', '2月', '3月', '4月', '5月'];
const data = [
  ['12', '30', '8', '0'],
  ['15', '24', '10', '2'],
  ['20', '18', '9', '5'],
  ['11', '9', '14', '13'],
  ['7', '6', '21', '25'],
];

export function App() {
  return <CellRangeTable label="月ごとの果物の数" columns={columns} rowHeaders={rowHeaders} data={data} />;
}

表の CSS のうち、主なところです。選んだセルの色は、aria-selected の値を CSS のセレクターで拾って付けています。

css
.cell-range {
  border-collapse: collapse;
  user-select: none;
  touch-action: none;
}
.cell-range td { cursor: cell; }
.cell-range td[aria-selected='true'] { background: #c4f1fa; }
.cell-range td.focus { outline: 2px solid #067a98; outline-offset: -2px; }
.cell-range:focus-visible { outline: 2px solid #00c8e8; outline-offset: 2px; }

user-select: none を付けた理由は、最後の節で書きます。touch-action: none は、MDN によると、ブラウザーがパンやズームのジェスチャーを扱わなくなる指定です。指で表をなぞったときにページがスクロールしないように付けています。

検証環境(Ubuntu 24.04、Chromium 153)で、この表をドラッグした結果です。セルの位置は「1月のりんご」のように、行と列の見出しで書いています。

ドラッグ 選んだセル 表の下の行
1月のりんご → 3月のみかん(右下へ) 6 6個のセル 合計 119
4月のもも → 2月のみかん(左上へ) 9 9個のセル 合計 104

左上へドラッグしたときも、2月〜4月、みかん〜ももの9つのセルが選ばれました。

ドラッグ中のセルは、座標から探す

pointerdown で setPointerCapture を呼んでいるので、ボタンを離すまで、ポインターのイベントは <table> に届きます。MDN の setPointerCapture() のページにあるとおり、キャプチャが解放されるまで、そのポインターのイベントはキャプチャした要素をターゲットにします。表の外でボタンを離しても pointerup が届くので、ドラッグが終わったことを取りこぼしません。

その代わり、pointermove の e.target は、ポインターの下にあるセルではなくなります。e.target から td を探すように書き換えたものと比べました。

diff
-    const cell = cellAt(e.clientX, e.clientY, e.currentTarget);
-    if (!cell) return; // 表の外では、最後のセルのまま
+    const td = (e.target as HTMLElement).closest<HTMLTableCellElement>("td[data-row]");
+    if (!td) return;
+    const cell = { row: Number(td.dataset.row), col: Number(td.dataset.col) };

1月のりんごから3月のみかんへドラッグすると、e.target で探す書き方では、選ばれたのは押した1つのセルだけでした。pointermove の e.target を記録すると、ボタンを押している間の12回は、すべて TABLE でした(TD だったのは、押す前にセルの上へ動かした1回だけです)。

そこで、記事のコードでは e.target を使わず、イベントの clientX と clientY を document.elementFromPoint() に渡しています。このメソッドは、ビューポート上の座標にある最上位の要素を返します。その要素から closest('td[data-row]') でセルまでたどり、別の表のセルを拾わないように table.contains(td) でも確かめています。

ポインターが表の外に出たときは、cellAt が null を返すので、端のセルは最後に通ったセルのままです。3月のぶどうから、表の右下の外まで引っぱって離すと、端は5月のもものところで止まり、3月〜5月、ぶどう〜ももの6つのセル(合計 87)が選ばれました。離した後、ボタンを押さずに表の上へポインターを戻しても、選択は変わりませんでした。

Shift と矢印キーで、範囲を伸ばす

Shift を押したまま別のセルをクリックしたときは、anchor を前のまま残し、focus だけを押したセルにしています。矢印キーも同じ考え方で、Shift があれば focus だけを1つ動かし、なければ anchor と focus を一緒に動かします。キーボードの割り当ては、WAI-ARIA Authoring Practices のグリッドのパターンで、Shift+矢印キーが「選択を1セル伸ばす」操作になっているのに合わせました。

表には tabIndex={0} を付けて、キーボードの操作を受け取れるようにしています。検証環境で、続けて操作した結果です。

操作 選んだセル 端のセル 表の下の行
1月のりんごをクリック 1 1月のりんご 1個のセル 合計 12
Shift を押して、2月のももをクリック 8 2月のもも 8個のセル 合計 101
Shift+↓ 12 3月のもも 12個のセル 合計 153
Shift+← 9 3月のぶどう 9個のセル 合計 146
→ 1 3月のもも 1個のセル 合計 5
→ をさらに3回 1 3月のもも 1個のセル 合計 5

Shift+← では、端が左へ1つ動いたので、ももの列が範囲から外れて9つに減りました。Shift なしの → では、範囲が端のセル1つに戻ります。右端で → を押しても、Math.min で止めているので、それ以上は動きません。onKeyDown で preventDefault() を呼んでいるのは、矢印キーでページがスクロールしないようにするためです。

<table> には role="grid" と aria-label を、セルには aria-selected を付けています。グリッドのパターンには、選択できるグリッドで選んだセルには aria-selected を true で付ける、と書かれています。MDN の aria-selected のページにも、複数のセルを選べるグリッドでは、grid ロールの要素に aria-multiselectable="true" を付けるとあります。Playwright の getByRole('grid', { name: '月ごとの果物の数' }) で表が1つ見つかり、getByRole('gridcell', { selected: true }) で数えたセルの数は、上の表の「選んだセル」と一致しました。

ただし、このコードはフォーカスを <table> に置いたままで、セル1つ1つにはフォーカスが移りません。グリッドのパターンでは、グリッドの各セルは、フォーカスできる要素を含むか、セル自身がフォーカスできることになっています。この記事のコードは、その点ではグリッドのパターンに沿っていません。

Ctrl+C で、選んだ範囲をタブ区切りでコピーする

選んだ範囲は、Ctrl+C でコピーできるようにしています。MDN の copy イベントのページにあるとおり、copy イベントのハンドラーでは、clipboardData の setData() でクリップボードの内容を変え、preventDefault() でもとの動き(選択した文字のコピー)を止められます。React では <table> の onCopy で受け取れます(React の共通コンポーネントのリファレンスに onCopy があります)。

入れるのは、toTSV で作る、セルをタブ、行を改行でつないだ文字列です。1月のりんご → 3月のみかんを選んで Ctrl+C を押したところ、クリップボードには次の文字列が入りました。下の textarea に Ctrl+V で貼り付けた値も同じでした。

text
"12\t30\n15\t24\n20\t18"

表には tabIndex={0} を付けているので、セルを押すと表にフォーカスが移ります。検証環境でも、ドラッグの後のフォーカスは表にあり、Ctrl+C でコピーの内容が差し替わりました。続けて ↓ を押すと、選択は4月のみかんの1つのセルに動きました。

ドラッグで、セルの文字まで選択されないようにする

CSS の user-select: none を外すと、セルの範囲選択とは別に、ブラウザーの文字の選択が始まってしまいます。同じドラッグをした後の表を並べました。

上は user-select: none を付けた記事のコードで、1月のりんごから3月のみかんまでの6つのセルが水色になり、ほかは何も変わっていない。下は user-select: none を外したもので、同じ6つのセルが水色になったうえに、1月の行の数字、2月の行の見出しと数字、3月の行の見出しとりんごの列の数字が、濃い青の文字選択の色で塗られている
user-select: none のある・なしで、1月のりんごから3月のみかんへドラッグした後の表

外した版では、getSelection().toString() に、1月の行の 12 から3月の行の 20 までの文字が、2月と3月の見出しも含めて入っていました。ハンドラーが copy の中身を差し替えているので、Ctrl+C でコピーされる内容は、外した版でも選んだ6つのセルのままでした。違いは見た目で、選んでいないセルの文字まで選択の色になります。MDN の user-select のページにあるとおり、none を付けた要素とその子孫の文章は範囲選択できなくなります。

文字の選択は、pointerdown で preventDefault() を呼んでも止まります。ただし、この方法ではキーボードとコピーが使えなくなりました。user-select: none を外し、setPointerCapture の前に e.preventDefault(); の1行を足したもので試した結果です。

user-select: none(記事のコード) pointerdown で preventDefault()
文字の選択 なし なし
ドラッグ後のフォーカス 表 body
Ctrl+C 6つのセルがコピーされる クリップボードは変わらない
↓ 選択が動く 選択は変わらない

preventDefault() を呼んだ版では、セルを押しても表にフォーカスが移らず、body のままでした。そのため、範囲は選べても、Ctrl+C を押したときにクリップボードは変わらず、↓ を押しても選択は動きませんでした。ドラッグ中の文字の選択は、表に user-select: none を付けて止めるほうが、フォーカスに影響しません。

​Posted by 86