Reactで、ドラッグで範囲を囲んで複数の項目を選ぶ方法

​​React​CSS,Playwright,Pointer Events,TypeScript

押した位置といまのポインターの位置から四角い枠を求め、各項目の四角と重なるかを調べれば、ライブラリを使わずに「ドラッグで囲んで選ぶ」操作を作れます。項目の位置は、押したときに getBoundingClientRect() で一度だけ測っておき、ドラッグ中は数字の比較だけで選ぶ項目を決めます。

あわせて、Shift を押したまま囲んで選択に足す方法、クリックとドラッグの区別、ドラッグ中にページの文字が選択されてしまうのを防ぐ CSS も、Playwright で動かして確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 で、Playwright でマウスとキーボードを操作して確かめました。タッチ操作、macOS(Command キーを押したクリック)、Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。項目の並ぶ領域がスクロールする場合や、項目がとても多い場合の動きも、この記事では扱いません。

何もない所から斜めにドラッグすると水色の枠が出て写真1・2・4・5が選ばれ、Shift を押したまま右下から囲むと写真9・12が足され、何もない所をクリックすると選択がすべて外れる。下の「…件を選択中」の行は、変わった瞬間だけピンクになる。項目は 3列に並んでいる
ドラッグで囲む・Shift を押して足す・何もない所をクリックして外す、という3回の操作(GIF は表示領域の幅 420px で撮影したので、項目は 3列に並ぶ。カーソルと操作の表示は GIF 用に重ねたもの)

枠を描いて、かかった項目を選ぶコンポーネント

MarqueeGrid は、項目の一覧と、選ばれている項目の id の Set を親から受け取ります。選ばれた項目が変わると、onSelectedChange で新しい Set を渡します。項目は button にして、選ばれているかどうかを aria-pressed で表しています。

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

export type Item = { id: number; name: string };

type Box = { left: number; top: number; right: number; bottom: number };

type Drag = {
  startX: number;
  startY: number;
  // ドラッグを始める前から選んであった項目(Shift などを押して始めたときだけ)
  base: Set<number>;
  // ドラッグを始める前の選択(取り消されたときに戻す)
  initial: Set<number>;
  // 押した時点で測った、各項目の位置(領域の左上からの px)
  rects: Map<number, Box>;
  // しきい値を超えて動いたか(false のまま離したらクリック)
  moved: boolean;
};

type Props = {
  items: Item[];
  selected: Set<number>;
  onSelectedChange: (next: Set<number>) => void;
  // 'touch':枠に少しでもかかった項目を選ぶ/'contain':枠に全体が入った項目だけを選ぶ
  mode?: 'touch' | 'contain';
};

// 押した位置から、この px より動いたら範囲選択とみなす(それまではクリック)
const THRESHOLD = 4;

const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max);

function hit(box: Box, r: Box, mode: 'touch' | 'contain') {
  if (mode === 'contain') {
    return r.left >= box.left && r.right <= box.right && r.top >= box.top && r.bottom <= box.bottom;
  }
  return r.left < box.right && r.right > box.left && r.top < box.bottom && r.bottom > box.top;
}

const sameSet = (a: Set<number>, b: Set<number>) => a.size === b.size && [...a].every((id) => b.has(id));

export function MarqueeGrid({ items, selected, onSelectedChange, mode = 'touch' }: Props) {
  const areaRef = useRef<HTMLDivElement>(null);
  const drag = useRef<Drag | null>(null);
  // 画面に描く枠。null のときは描かない
  const [box, setBox] = useState<Box | null>(null);

  // ポインターの位置を、領域の左上からの px に直す(領域の外は端に寄せる)
  function toLocal(e: PointerEvent) {
    const area = areaRef.current!;
    const r = area.getBoundingClientRect();
    return {
      x: clamp(e.clientX - r.left - area.clientLeft, 0, area.clientWidth),
      y: clamp(e.clientY - r.top - area.clientTop, 0, area.clientHeight),
    };
  }

  function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
    // 項目の上で押したときは、項目のクリックに任せる
    if (e.button !== 0 || (e.target as HTMLElement).closest('[data-id]')) return;
    const area = areaRef.current!;
    const a = area.getBoundingClientRect();
    const rects = new Map<number, Box>();
    area.querySelectorAll<HTMLElement>('[data-id]').forEach((el) => {
      const r = el.getBoundingClientRect();
      const left = r.left - a.left - area.clientLeft;
      const top = r.top - a.top - area.clientTop;
      rects.set(Number(el.dataset.id), { left, top, right: left + r.width, bottom: top + r.height });
    });
    const { x, y } = toLocal(e);
    const keep = e.shiftKey || e.ctrlKey || e.metaKey;
    drag.current = { startX: x, startY: y, base: keep ? new Set(selected) : new Set(), initial: selected, rects, moved: false };
    // ポインターが領域の外へ出ても、離すまでこの要素にイベントが届くようにする
    e.currentTarget.setPointerCapture(e.pointerId);
  }

  function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
    const d = drag.current;
    if (!d) return;
    const { x, y } = toLocal(e);
    if (!d.moved && Math.hypot(x - d.startX, y - d.startY) <= THRESHOLD) return;
    d.moved = true;
    const next: Box = {
      left: Math.min(d.startX, x),
      top: Math.min(d.startY, y),
      right: Math.max(d.startX, x),
      bottom: Math.max(d.startY, y),
    };
    setBox(next);
    const ids = new Set(d.base);
    for (const [id, r] of d.rects) if (hit(next, r, mode)) ids.add(id);
    // 選ばれた項目が変わったときだけ親に知らせる
    if (!sameSet(ids, selected)) onSelectedChange(ids);
  }

  function handlePointerUp(e: PointerEvent<HTMLDivElement>) {
    const d = drag.current;
    if (!d) return;
    drag.current = null;
    if (e.type === 'pointercancel') {
      // ブラウザに操作を取り上げられたときは、始める前の選択に戻す
      if (d.moved) onSelectedChange(d.initial);
    } else if (!d.moved && !(e.shiftKey || e.ctrlKey || e.metaKey) && selected.size > 0) {
      // 何もない所をクリックしただけなら、選択を外す
      onSelectedChange(new Set());
    }
    setBox(null);
  }

  function handleItemClick(e: MouseEvent<HTMLButtonElement>, id: number) {
    // Ctrl(Mac は Command)を押したクリックと、キーボードでの操作(detail が 0)は、その項目だけを切り替える
    if (e.ctrlKey || e.metaKey || e.detail === 0) {
      const next = new Set(selected);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      onSelectedChange(next);
    } else {
      onSelectedChange(new Set([id]));
    }
  }

  return (
    <div
      className="marquee-area"
      ref={areaRef}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      onPointerCancel={handlePointerUp}
    >
      {items.map((item) => (
        <button
          key={item.id}
          type="button"
          data-id={item.id}
          className="marquee-item"
          aria-pressed={selected.has(item.id)}
          onClick={(e) => handleItemClick(e, item.id)}
        >
          {item.name}
        </button>
      ))}
      {box && (
        <div
          className="marquee-box"
          style={{ left: box.left, top: box.top, width: box.right - box.left, height: box.bottom - box.top }}
        />
      )}
    </div>
  );
}

押したときの位置、始める前の選択、各項目の位置は、drag という ref に入れています。ref を書き換えても再レンダリングは起きないので、画面に出さない値を置くのに向いています。画面に描く枠(box)だけを state にしています。

項目の位置は、押したときに一度だけ測る

handlePointerDown では、各項目の getBoundingClientRect() を呼び、領域の左上からの位置に直して rects に入れています。getBoundingClientRect() が返す値はブラウザの表示領域からの位置なので、領域の位置(と枠線の太さ clientLeft・clientTop)を引いて、枠と同じ基準にそろえます。

ドラッグ中の handlePointerMove は、測っておいた rects と枠の四角を比べるだけで、項目の DOM には触れません。そのかわり、ドラッグの途中で項目の位置が変わると、測った値とずれます。領域をスクロールできるようにする場合などは、別の手当てが必要になります(この記事では試していません)。

枠に「かかった」か「全体が入った」か

選ぶ条件は hit で決めています。mode が 'touch' のときは、枠と項目の四角が少しでも重なれば選びます。'contain' のときは、項目の四角が枠の中に全部入ったときだけ選びます。

クリックとドラッグを分ける

押してから 4px(THRESHOLD)より動くまでは、枠を描きません。動かないまま離したときはクリックとして扱い、何もない所のクリックなら選択を外します。手が少しぶれただけで、選択が消えたり枠が出たりしないようにするためです。

項目の上で押したときは、handlePointerDown は何もせず、項目の onClick に任せます。ふつうのクリックはその項目だけを選び、Ctrl(Mac では Command)を押したクリックは、その項目を選択に足したり外したりします。Shift を押したドラッグは選択に足しますが、Shift を押した項目のクリックは、このコードではふつうのクリックと同じ扱いです。

取り消されたときと、領域の外へ出たとき

ドラッグの途中で pointercancel が届いたときは、始める前の選択(initial)に戻します。pointercancel は、押したあとでブラウザがスクロールや拡大を始めたときなどに送られるイベントです。

また、押したときに setPointerCapture() を呼んでいるので、そのポインターのイベントは、離すまで領域の要素に届きます。ポインターが領域の外へ出たときは、toLocal で位置を領域の端に寄せ、枠が領域からはみ出さないようにしています。

選んだ項目を持つ App

使う側の App は、選ばれた項目の id を Set にして state に持ちます。onSelectedChange には、毎回新しい Set が渡されるので、setSelected をそのまま渡せます。

tsx
import { useState } from 'react';
import { MarqueeGrid } from './MarqueeGrid';
import type { Item } from './MarqueeGrid';

const items: Item[] = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, name: `写真${i + 1}` }));

export function App() {
  const [selected, setSelected] = useState<Set<number>>(() => new Set());
  // 確かめるために、URL の ?mode=contain で選び方を切り替える
  const mode = new URLSearchParams(location.search).get('mode') === 'contain' ? 'contain' : 'touch';

  return (
    <main>
      <MarqueeGrid items={items} selected={selected} onSelectedChange={setSelected} mode={mode} />
      <p className="info" aria-live="polite">
        {selected.size}件を選択中{selected.size > 0 && `:${[...selected].sort((a, b) => a - b).join(', ')}`}
      </p>
    </main>
  );
}

検証のために、URL に ?mode=contain を付けたときだけ、選び方を 'contain' に切り替えています。

見た目は CSS で付けています。領域には position: relative、枠には position: absolute を指定し、枠の位置は領域の左上からの px で決めます。枠には pointer-events: none を付けて、枠の上でもイベントが下の領域に届くようにしています。

css
.marquee-area {
  position: relative;
  display: grid;
  grid-template-columns: repeat(auto-fill, 104px);
  gap: 16px;
  padding: 16px;
  background: #eefafd;
  border: 1px solid #9aa5ab;
  border-radius: 8px;
  /* ドラッグしたときに、文字が選択されないようにする */
  user-select: none;
  /* タッチでドラッグしたときに、ページがスクロールしないようにする */
  touch-action: none;
}

.marquee-item {
  height: 56px;
  border: 2px solid #c5d3d9;
  border-radius: 6px;
  background: #fff;
  color: inherit;
  font: inherit;
}
.marquee-item[aria-pressed='true'] {
  border-color: #00c8e8;
  background: #c9f1fa;
}
.marquee-item:focus-visible { outline: 3px solid #067a98; outline-offset: 2px; }

.marquee-box {
  position: absolute;
  border: 1px solid #067a98;
  background: rgb(0 200 232 / 0.15);
  pointer-events: none;
}

touch-action: none は、タッチでドラッグしたときに、ブラウザにスクロールや拡大をさせないための指定です。MDN の touch-action の説明によると、none を指定した要素の上では、ブラウザはスクロールも拡大もしません。領域が画面より大きくなるときや、拡大して見たい人がいる画面では、指定する範囲を見直してください。筆者はタッチ操作では試していません。

ドラッグしたときの動き

ブラウザの表示領域の幅が 640px のとき、領域の内側は 598×232px で、104×56px の項目が 4列×3行に並びました。座標は、領域の左上(枠線の内側)からの px です。

text
領域の内側: 598×232 / 項目の大きさ: 104×56 / 写真1 (16, 16)・写真2 (136, 16)・写真4 (376, 16)・写真5 (16, 88)・写真12 (376, 160)
0件を選択中
(8, 8) から (250, 100) まで動かした途中: 枠 left 8px top 8px width 242px height 92px / 4件を選択中:1, 2, 5, 6
離したあと: 4件を選択中:1, 2, 5, 6 / 枠の表示 0個
修飾キーなしで (368, 80) から (590, 230) まで: 2件を選択中:8, 12
(8, 8)〜(250, 100) を囲んだあと、Shift を押したまま (368, 80) から (590, 230) まで: 6件を選択中:1, 2, 5, 6, 8, 12

(8, 8) から (250, 100) まで動かすと、枠は左上 (8, 8)、幅 242px・高さ 92px で描かれました。写真1・2(上の段)と写真5・6(2段目)は、枠と少しでも重なっているので選ばれています。離すと、選択はそのまま残り、枠は消えました。

修飾キーなしで別の所を囲むと、それまでの選択は外れて、写真8・12だけになりました。Shift を押したまま囲んだときは、前の選択に写真8・12が足されています。始める前の選択を base に取っておき、そこへ枠にかかった項目を足しているからです。

text
(250, 100) まで広げたとき: 4件を選択中:1, 2, 5, 6 / (100, 60) まで戻したとき: 1件を選択中:1

枠を広げたあとで小さくすると、枠から外れた項目は選択から外れました。ドラッグ中は、毎回 base から選び直しているので、一度かかった項目が残り続けることはありません。

クリックとキーボードでの選び方

マウスのクリックと、Ctrl を押したクリックで選んだ結果です。

text
項目のすき間 (128, 80) をクリック: 0件を選択中
写真3をクリック: 1件を選択中:3
Ctrl を押して写真7と写真10をクリック: 3件を選択中:3, 7, 10
Ctrl を押して写真3をもう一度クリック: 2件を選択中:7, 10
修飾キーなしで写真5をクリック: 1件を選択中:5
すき間で押して右へ 3px だけ動かして離す: 0件を選択中 / 枠の表示 0個

すき間を押して右へ 3px だけ動かして離したときは、枠は出ず、クリックとして選択が外れました。

キーボードでは、写真2から始めて Tab で次の項目へ移り、Space か Enter を押すと、その項目が選択に足されました。選ばれている写真4でもう一度 Space を押すと、写真4だけが外れました。handleItemClick で、キーボードの操作を Ctrl を押したクリックと同じに扱っているからです。キーボードで起きた click イベントの detail(クリックの回数)は、検証環境の Chromium では 4回とも 0 で、マウスでクリックしたときは 1 でした。

text
写真2で Space、Tab で写真3へ移って Enter、Tab で写真4へ移って Space: 3件を選択中:2, 3, 4 / aria-pressed="true" の項目: 写真2, 写真3, 写真4
続けて写真4でもう一度 Space: 2件を選択中:2, 3
キーボードで起きた click の detail: 0, 0, 0, 0
マウスで写真6をクリック: 1件を選択中:6 / click の detail: 1

もし e.detail === 0 を見ないと、コードの上では、キーボードで次の項目を選ぶたびに前の選択が外れてしまいます。マウスを使わない人も複数の項目を選べるように、この分け方にしました。選ばれているかどうかは aria-pressed で表しています。

途中で pointercancel が届いたとき

写真3・7を選んだ状態から、修飾キーなしで囲んでいる途中に、領域へ pointercancel を送りました。実際のタッチ操作の代わりに、Playwright の dispatchEvent() でイベントを送っています。

text
写真3・7を選んだ状態: 2件を選択中:3, 7 / 修飾キーなしで囲んでいる途中: 4件を選択中:1, 2, 5, 6 / pointercancel のあと: 2件を選択中:3, 7 / 枠の表示 0個
そのあとボタンを離す: 2件を選択中:3, 7

囲んでいる途中では写真1・2・5・6に変わっていた選択が、pointercancel のあとは写真3・7に戻り、枠も消えました。そのあとボタンを離しても、選択は変わりません。

領域の外まで引っぱったとき

text
(8, 8) から、領域の右下の外 (800, 400) まで動かして、外で離す: 枠 left 8px top 8px width 590px height 224px / 12件を選択中:1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 / 離したあとの枠の表示 0個
ボタンを押さずに領域の中へ戻す: 12件を選択中:1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12

領域の右下の外まで動かしても、枠は左上 (8, 8)、幅 590px・高さ 224px で止まりました。598 − 8 = 590、232 − 8 = 224 で、ちょうど領域の内側の右下の端までです。12件すべてが選ばれ、外で離したあとは枠が消えました。そのあとボタンを押さずに領域の中へ戻しても、選択は変わりません。離したときの pointerup が領域に届き、ドラッグが終わったことが伝わっています。

「かかった項目」と「全体が入った項目」の違い

同じ (8, 8) から (250, 100) までのドラッグを、mode だけ変えて比べました。

text
mode 'touch': (8, 8) から (250, 100) まで: 4件を選択中:1, 2, 5, 6
mode 'contain': (8, 8) から (250, 100) まで: 2件を選択中:1, 2

枠の下の辺は y = 100 で、2段目の写真5・6(上の辺が y = 88、下の辺が y = 144)には少しだけかかります。'touch' ではこの2つも選ばれ、'contain' では全体が入った写真1・2だけが選ばれました。少し触れただけで選ばれるのが困る場面では、'contain' が向いています。

ドラッグで、ページの文字まで選択されてしまう

範囲選択の領域は、ブラウザにとってはただの要素の集まりです。そのままでは、何もない所から押してドラッグすると、ブラウザの文字の選択も始まります。CSS の user-select: none を外して、領域の (8, 8) から、その下の「…件を選択中」の行の下までドラッグし、getSelection() で選択された文字列を調べました。

text
user-select: none あり: 領域の (8, 8) から、下の「…件を選択中」の行の下まで: 6件を選択中:1, 2, 5, 6, 9, 10 / 選択された文字列: ""
user-select: none なし: 領域の (8, 8) から、下の「…件を選択中」の行の下まで: 6件を選択中:1, 2, 5, 6, 9, 10 / 選択された文字列: "写真1\n写真2\n写真3\n写真4\n写真5\n写真6\n写真7\n写真8\n写真9\n写真10"
user-select: none なし、pointerdown で preventDefault(): 領域の (8, 8) から、下の「…件を選択中」の行の下まで: 6件を選択中:1, 2, 5, 6, 9, 10 / 選択された文字列: ""

user-select: none がないと、項目の選択とは別に、項目の文字(写真1〜写真10)がページの文字として選択されました。user-select: none を付けた領域では、その中の文字が選択できなくなり、選択された文字列は空でした。

user-select: none を付けずに、pointerdown で preventDefault() を呼んだ場合も、検証環境の Chromium では文字は選択されませんでした(検証では、document に足した pointerdown のリスナーで呼んでいます)。Firefox と Safari では試していないので、この記事では CSS で止める書き方にしています。

ポイント

範囲選択の領域には user-select: none を付けます。付けないと、項目を囲むのと同時に、ページの文字の選択も始まります。

​Posted by 86