テーブル(Table)— Shift+クリック範囲選択

データ表示 初級

このコンポーネントについて

テーブルの行チェックボックスを、Excelのシートやファイル一覧アプリのように「Shift+クリックでまとめて選択」できるようにする実装例です。ある行をクリックしたあと、離れた行をShift+クリックすると、直前にクリックした行から今回クリックした行までの範囲が一括で選択・解除されます。全選択チェックボックス(data-table-6)の構成をそのまま引き継いでおり、選択状態の管理(Set によるデータ管理・ヘッダーの自動追従・indeterminate表示)は共通です。本事例はこの土台に「範囲選択」という1機能を追加したタスク一覧のバリエーションです。

  • Shift+クリックによる範囲選択 — 直前にクリックした行を起点(アンカー)として、Shift+クリックした行までの範囲を一括ON/OFFする。上方向・下方向どちらにも対応
  • アンカーは毎回のクリックで更新 — 固定された起点ではなく、直近のクリック行が次のアンカーになる。連続してShift+クリックすると、そのつど新しい範囲を選択し直せる
  • クリックした側の状態に揃える — Shift+クリックした行がONになった場合は範囲全体をON、OFFになった場合は範囲全体をOFFにする
  • テキスト選択の暴発を防止 — Shift+クリック時のみ mousedownpreventDefault() し、ブラウザの範囲テキスト選択を抑止する。通常のクリック時やセルの値のドラッグ選択・コピーは妨げない
  • 全選択・indeterminate・選択件数カウンター — 選択状態を Set で保持し、ヘッダー状態の更新を1つの関数に集約する設計を data-table-6 から踏襲する
  • createElement による安全な行生成 — 行の描画は createElementtextContent で行い、innerHTML に値を結合しない(XSS対策)

実装のポイント・注意点

範囲選択の起点(アンカー)は「最初にクリックした行」ではなく、直近にクリックした行のindexを毎回更新して保持します。Excel・Gmail等の実際の範囲選択UIも直近のクリックを起点にしており、連続してShift+クリックすると範囲を継ぎ足していける挙動の方が業務テーブルの操作感として自然だからです。ヘッダーの全選択チェックボックスの操作はアンカーを更新しません(行をクリックしたわけではないため)。

change イベントが発火する時点で、クリックされた行の checkedすでにブラウザによってトグル済みの結果です。そのため「クリック前の状態を反転する」といった計算は不要で、この結果状態(ON/OFF)をそのまま範囲全体に適用するだけで済みます。ただしchange イベントには shiftKey プロパティが乗りませんchangeMouseEvent ではなくプレーンな Event のため)。そこでShiftキーの押下状態は、先に発火する mousedownMouseEvent)の時点で変数に保存しておき、change ハンドラ内ではその保存値を参照します。ここを見落として change ハンドラで直接 e.shiftKey を読むと、Shift+クリックしても常に undefined(falsy)になり範囲選択が一切発動しません。

Shift+クリック時にブラウザ標準のテキスト選択(青いハイライト)が暴発する問題は、テーブル全体に user-select: none をかけるのではなく、Shiftキーが押されている場合のみ mousedownpreventDefault() することで防いでいます。データテーブルはセルの値をコピーしたい業務ニーズがあるため、通常時のテキスト選択までは殺さない設計です。なお本事例はマウス操作を前提としており、タッチ操作でのShift範囲選択やキーボードのShift+矢印キーによる範囲選択には対応していません。

HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。

デモ

0件選択中

Shift+クリックで範囲選択できます

ID タスク名 担当者 期限 状態

サンプルソース

3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
ファイル名:index.html / style.css / script.js — 保存時の文字コードは UTF-8 を指定してください(Shift-JISだと日本語が文字化けします)。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>テーブルのShift+クリック範囲選択 サンプル</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<div class="dt7-wrap">
  <!-- ツールバー(選択件数 + 一括操作の起点) -->
  <div class="dt7-toolbar">
    <span class="dt7-count" id="dt7-count" aria-live="polite">0件選択中</span>
    <button class="dt7-clear-btn" id="dt7-clear" disabled>選択を解除</button>
  </div>
  <p class="dt7-hint">Shift+クリックで範囲選択できます</p>
  <div class="dt7-table-scroll">
    <table class="dt7-table">
      <thead>
        <tr>
          <th class="dt7-check-col">
            <input type="checkbox" id="dt7-select-all" aria-label="すべて選択">
          </th>
          <th class="dt7-id-col">ID</th>
          <th>タスク名</th>
          <th>担当者</th>
          <th>期限</th>
          <th>状態</th>
        </tr>
      </thead>
      <tbody id="dt7-tbody"><!-- 行はJSで動的生成 --></tbody>
    </table>
  </div>
</div>

<script src="./script.js"></script>
</body>
</html>
:root {
  --dt7-primary: #2B7FE8;
  --dt7-border: #E2E8F0;
  --dt7-text: #1A2332;
  --dt7-text-sub: #5A6A7A;
  --dt7-head-bg: #F4F7FF;
  --dt7-selected-bg: #EBF2FF;
}

*, *::before, *::after { box-sizing: border-box; }

body {
  font-family: sans-serif;
  padding: 24px;
  background: #F0F2F5;
  color: var(--dt7-text);
}

.dt7-wrap {
  width: 100%;
  max-width: 720px;
  margin: 0 auto;
}

/* ===== ツールバー ===== */
.dt7-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 12px;
}

.dt7-count {
  font-size: 13px;
  font-weight: 600;
  color: var(--dt7-text-sub);
}

.dt7-clear-btn {
  padding: 6px 14px;
  font-size: 13px;
  color: var(--dt7-primary);
  background: #fff;
  border: 1.5px solid #C7D9F5;
  border-radius: 6px;
  cursor: pointer;
  font-family: sans-serif;
  transition: background 0.15s, border-color 0.15s;
}

.dt7-clear-btn:hover:not(:disabled) {
  background: #F0F6FF;
  border-color: var(--dt7-primary);
}

.dt7-clear-btn:disabled {
  color: #A9B4C2;
  border-color: #E2E8F0;
  background: #F7F9FC;
  cursor: not-allowed;
}

/* ===== 操作ヒント ===== */
.dt7-hint {
  margin: 0 0 12px;
  font-size: 12px;
  color: var(--dt7-text-sub);
}

/* ===== テーブル ===== */
.dt7-table-scroll { overflow-x: auto; }

.dt7-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
  color: var(--dt7-text);
  background: #fff;
}

.dt7-table th,
.dt7-table td {
  padding: 12px 16px;
  text-align: left;
  border-bottom: 1px solid var(--dt7-border);
  white-space: nowrap;
}

.dt7-table thead th {
  background: var(--dt7-head-bg);
  font-weight: 600;
  color: var(--dt7-text-sub);
}

.dt7-check-col {
  width: 44px;
  text-align: center;
}

.dt7-id-col { width: 56px; }

.dt7-table input[type="checkbox"] {
  width: 16px;
  height: 16px;
  cursor: pointer;
  accent-color: var(--dt7-primary);
}

/* ホバー → 選択の順で定義し、選択ハイライトを優先させる */
.dt7-table tbody tr:hover td {
  background: #F4F7FF;
  transition: background 0.1s;
}

.dt7-table tbody tr.is-selected td {
  background: var(--dt7-selected-bg);
}

/* ===== 状態バッジ ===== */
.dt7-status {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 9999px;
  font-size: 12px;
  font-weight: 600;
}

.dt7-status[data-status="未着手"] { background: #F3F4F6; color: #6B7280; }
.dt7-status[data-status="対応中"] { background: #DBEAFE; color: #1E40AF; }
.dt7-status[data-status="完了"]   { background: #D1FAE5; color: #065F46; }

@media (max-width: 600px) {
  .dt7-table th,
  .dt7-table td { padding: 8px 12px; }
}
// タスク一覧(行データはJavaScriptの配列で保持する)
const tasks = [
  { id: 1, name: '見積書テンプレートの改訂', assignee: '田中 一郎', due: '2026-07-15', status: '対応中' },
  { id: 2, name: '請求データの月次集計',     assignee: '鈴木 花子', due: '2026-07-10', status: '未着手' },
  { id: 3, name: '顧客マスタの重複チェック', assignee: '佐藤 次郎', due: '2026-07-08', status: '完了'   },
  { id: 4, name: 'リリースノートの作成',     assignee: '山田 三枝', due: '2026-07-20', status: '未着手' },
  { id: 5, name: '問い合わせの一次対応',     assignee: '田中 一郎', due: '2026-07-09', status: '対応中' },
  { id: 6, name: 'サーバー証明書の更新',     assignee: '高橋 健',   due: '2026-07-12', status: '未着手' },
  { id: 7, name: '議事録の共有',             assignee: '鈴木 花子', due: '2026-07-07', status: '完了'   },
  { id: 8, name: '予算申請の承認依頼',       assignee: '伊藤 美咲', due: '2026-07-18', status: '対応中' }
];

// 選択中の行IDを Set で保持する(DOMのcheckedを状態の正としない)
const selected = new Set();

// 直前にクリックした行のindexを保持する(Shift+クリック範囲選択の起点。固定せず毎クリック更新する)
let anchorIndex = null;

// change イベントには shiftKey が乗らないため、mousedown 時点のShift押下状態を一時保存する
let shiftKeyPending = false;

const tbody     = document.getElementById('dt7-tbody');
const selectAll = document.getElementById('dt7-select-all');
const countEl   = document.getElementById('dt7-count');
const clearBtn  = document.getElementById('dt7-clear');

// テキストセルを作る(textContent で安全に表示)
function makeCell(value, className) {
  const td = document.createElement('td');
  if (className) td.className = className;
  td.textContent = value;
  return td;
}

// 行を生成する(createElement で組み立て、innerHTML は使わない)
function renderRows() {
  tbody.textContent = '';
  tasks.forEach(function (task, index) {
    const tr = document.createElement('tr');
    tr.dataset.id = task.id;

    // チェックボックス列
    const tdCheck = document.createElement('td');
    tdCheck.className = 'dt7-check-col';
    const cb = document.createElement('input');
    cb.type = 'checkbox';
    cb.className = 'dt7-row-check';
    cb.dataset.id = task.id;
    cb.dataset.index = index;
    cb.setAttribute('aria-label', task.name + ' を選択');
    // Shift+クリック時だけテキスト選択の暴発を防ぐ(通常のセルコピーは妨げない)。
    // change イベントは shiftKey を持たないため、ここで押下状態を記憶しておく
    cb.addEventListener('mousedown', function (e) {
      shiftKeyPending = e.shiftKey;
      if (e.shiftKey) e.preventDefault();
    });
    cb.addEventListener('change', onRowChange);
    tdCheck.appendChild(cb);
    tr.appendChild(tdCheck);

    // データ列
    tr.appendChild(makeCell(task.id, 'dt7-id-col'));
    tr.appendChild(makeCell(task.name));
    tr.appendChild(makeCell(task.assignee));
    tr.appendChild(makeCell(task.due));

    // 状態バッジ
    const tdStatus = document.createElement('td');
    const badge = document.createElement('span');
    badge.className = 'dt7-status';
    badge.dataset.status = task.status;
    badge.textContent = task.status;
    tdStatus.appendChild(badge);
    tr.appendChild(tdStatus);

    tbody.appendChild(tr);
  });
}

// 個別チェックの変更 → Set を更新して各表示を再導出
// Shift+クリック時は、直前にクリックした行(アンカー)からの範囲を一括で更新する
function onRowChange(e) {
  const id = Number(e.target.dataset.id);
  const index = Number(e.target.dataset.index);
  const nowChecked = e.target.checked; // change時点で既にトグル済みの結果状態

  if (shiftKeyPending && anchorIndex !== null) {
    const start = Math.min(anchorIndex, index);
    const end = Math.max(anchorIndex, index);
    for (let i = start; i <= end; i++) {
      if (nowChecked) {
        selected.add(tasks[i].id);
      } else {
        selected.delete(tasks[i].id);
      }
    }
  } else {
    if (nowChecked) {
      selected.add(id);
    } else {
      selected.delete(id);
    }
  }

  anchorIndex = index; // クリックした行を次回の起点として更新する
  shiftKeyPending = false; // 使い終わったら必ずリセットする
  syncChecks();
  syncHighlight();
  updateSelectAllState();
}

// ヘッダーの全選択 → 全ID追加 or 全クリア(アンカーは変更しない)
function onSelectAll() {
  if (selectAll.checked) {
    tasks.forEach(function (t) { selected.add(t.id); });
  } else {
    selected.clear();
  }
  syncChecks();
  syncHighlight();
  updateSelectAllState();
}

// Set をもとに各行の checked を再同期する
function syncChecks() {
  tbody.querySelectorAll('.dt7-row-check').forEach(function (cb) {
    cb.checked = selected.has(Number(cb.dataset.id));
  });
}

// Set をもとに行ハイライトを再同期する
function syncHighlight() {
  tbody.querySelectorAll('tr').forEach(function (tr) {
    tr.classList.toggle('is-selected', selected.has(Number(tr.dataset.id)));
  });
}

// ヘッダー状態・カウンター・ボタンを1関数で更新する(状態更新の集約)
function updateSelectAllState() {
  const size = selected.size;
  const total = tasks.length;

  if (size === 0) {
    selectAll.checked = false;
    selectAll.indeterminate = false;
  } else if (size === total) {
    selectAll.checked = true;
    selectAll.indeterminate = false;
  } else {
    // 一部だけ選択 → 中間状態(JSプロパティでのみ設定できる)
    selectAll.checked = false;
    selectAll.indeterminate = true;
  }

  countEl.textContent = size + '件選択中';
  clearBtn.disabled = (size === 0);
}

// 選択と起点をクリアして初期状態へ戻す
function clearSelection() {
  selected.clear();
  anchorIndex = null;
  shiftKeyPending = false;
  syncChecks();
  syncHighlight();
  updateSelectAllState();
}

selectAll.addEventListener('change', onSelectAll);
clearBtn.addEventListener('click', clearSelection);

renderRows();
updateSelectAllState();

AI用プロンプト

以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。

ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。ライブラリ指定や列数変更など、要件を追記して使うのがおすすめです。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

# テーブルのShift+クリック範囲選択 作成依頼

## 概要
一覧テーブルの行チェックボックスを、Excelのシートのように「Shift+クリックで範囲選択」できるようにしてください。ある行をクリックしたあと、離れた行をShift+クリックすると、その間の行が一括で選択・解除されるようにします。題材はタスク一覧です。

## 要件
- 列は「選択(チェックボックス)/ID/タスク名/担当者/期限/状態」。状態は未着手・対応中・完了の色付きバッジで表示する
- 行データはJavaScriptの配列で持ち、行はDOMで動的生成する
- 通常のクリックは、その行だけを選択/解除する
- Shift+クリックすると、直前にクリックした行から今回クリックした行までの範囲を、クリックした行の結果状態(ON/OFF)に揃えて一括選択/解除する。範囲は上方向・下方向どちらにも対応する
- 「直前にクリックした行」はクリックのたびに更新し、続けてShift+クリックすると新しい範囲を選択し直せるようにする
- ヘッダーに全選択チェックボックスを置き、個別チェックの結果に応じて自動更新する(0件=未チェック、全件=チェック、一部=indeterminate)
- ツールバーに「N件選択中」のカウンターを表示し、選択0件のときは「選択を解除」ボタンをdisabledにする
- Shift+クリックで範囲選択できる旨のヒント文をツールバー付近に表示する
- 「選択を解除」ボタンとリセットボタンで、全解除の初期状態に戻す

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要

## 動作詳細
選択状態はDOMのcheckedではなく、選択中の行IDを保持するSetで管理してください。
「直前にクリックした行のindex」を変数で保持し、Shift+クリック時にその変数から今回クリックした行までの範囲を計算してください。この変数は通常のクリック・Shift+クリックのいずれでも、クリックした行のindexで毎回更新してください(起点を固定しないでください)。
Shift+クリック時にブラウザの標準テキスト選択が発生しないよう、Shiftキーが押されている場合のみ mousedown イベントで preventDefault() してください(通常のクリック時やセルのテキストコピーは妨げないでください)。
ヘッダーの中間状態は el.indeterminate = true というJavaScriptのプロパティで設定してください(HTML属性では指定できません)。
行の描画は createElement と textContent で行い、innerHTML に値を結合しないでください。

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。