以下の内容はhttps://hhelibex.hatenablog.jp/entry/2025/10/14/161633より取得しました。


tableの行をカーソルキー操作(Ctrl、Shift付きも)で選択する

はじめに

ちょっとお仕事で必要が生じて、HTMLのテーブルの行をカーソルキー操作(Ctrlキー、Shiftキーが押された場合も含む)で選択状態変更する機能を実装することになったので、その検証メモ。

今回は、Laravelを使って組んでいく。

なお、実行環境は以下だが、構築の詳細は割愛。

下準備

まずはLaravelのプロジェクトを作る。

cd /var/www/html
composer create-project laravel/laravel:11.* sample05
chmod -R a+w  sample05/storage/
chmod a+w  sample05/bootstrap/cache

お仕事がLaravel 11なので、バージョンとしてLaravel 11系を指定しているが、最新のLaravel 12系でも問題なく動くと思う。

ファイルの作成・編集

sample05/resources/views/sample/index.blade.php

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8" />
    <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
    <link rel="stylesheet" type="text/css" href="sample.css" />
    <script type="text/javascript" src="sample.js"></script>
</head>

<body>
    <table id="listTable" tabindex="0">
        <thead>
            <tr>
                <th>
                    <div></div>
                </th>
                <th>
                    <div></div>
                </th>
                <th>
                    <div>メールアドレス</div>
                </th>
            </tr>
        </thead>
        <tbody>
            @for ($i = 1; $i <= 100; ++$i)
                <tr class="selectable">
                <td>姓{{mb_convert_kana($i, 'N')}}</td>
                <td>名{{mb_convert_kana($i, 'N')}}</td>
                <td>メールアドレス{{mb_convert_kana($i, 'N')}}</td>
                </tr>
                @endfor
        </tbody>
    </table>
    <script type="text/javascript">
        initSelectingRows('listTable');
    </script>
</body>

</html>

sample05/app/Http/Controllers/SampleController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class SampleController extends Controller
{
    public function index()
    {
        return view('sample.index');
    }
}

今回も、クライアント側の処理だけなので、コントローラーは要らなかったかも。

sample05/public/sample.css

table {
    border-collapse: collapse;
    outline: none;
}
th,
td {
    border: 1px solid #ccc;
}
.selectable:hover {
    background-color: #f0f8ff;
}
.selectable.selected {
    background-color: #d0d8ff; /* 選択時の背景色 */
    border-left: 3px solid #007bff; /* 選択時の左ボーダー */
}
.selectable.focused {
    border: 2px dashed #ccc;
}

sample05/public/sample.js

function initSelectingRows(id) {
    const table = document.querySelector("#" + id);
    const rows = table.querySelectorAll("tr.selectable");
    let selectedIndex = -1;
    let focusedIndex = -1;
    let addedByArrowKey = new Array();
    let rangeStartIndex = -1;

    // 初期フォーカス設定
    if (rows.length > 0) {
        selectedIndex = 0;
        rows[selectedIndex].classList.add("selected");
        focusedIndex = 0;
        rows[selectedIndex].classList.add("focused");
    }

    function handleKeyDown(ev) {
        // テーブル全体にフォーカスがある場合のみ処理
        if (document.activeElement !== table) {
            return;
        }

        const isCtrl = ev.ctrlKey || ev.metaKey;
        const isShift = ev.shiftKey;

        if (!isShift) {
            addedByArrowKey.splice(0, addedByArrowKey.length);
            if (!isCtrl) {
                rows.forEach((row, index) => {
                    if (index !== selectedIndex) {
                        row.classList.remove("selected");
                    }
                });
            }
        }

        let newIndex = -1;
        if (isCtrl) {
            newIndex = focusedIndex;
        } else if (selectedIndex !== focusedIndex) {
            newIndex = focusedIndex;
        } else {
            newIndex = selectedIndex;
        }
        if (isShift) {
            if (rangeStartIndex === -1) {
                rangeStartIndex = newIndex;
            }
        } else {
            rangeStartIndex = -1;
        }

        // 下矢印キー(ArrowDown)
        if (ev.key === "ArrowDown") {
            if (isCtrl || selectedIndex !== focusedIndex) {
                if (focusedIndex < rows.length - 1) {
                    ++newIndex;
                }
            } else {
                if (selectedIndex < rows.length - 1) {
                    ++newIndex;
                }
            }
        }
        // 上矢印キー(ArrowUp)
        else if (ev.key === "ArrowUp") {
            if (isCtrl || selectedIndex !== focusedIndex) {
                if (focusedIndex > 0) {
                    --newIndex;
                }
            } else {
                if (selectedIndex > 0) {
                    --newIndex;
                }
            }
        }
        // それ以外はスキップ
        else {
            return;
        }

        if (newIndex !== selectedIndex || newIndex !== focusedIndex) {
            if (!isCtrl) {
                // 古い行の選択を解除
                if (selectedIndex >= 0 && selectedIndex < rows.length) {
                    if (isShift) {
                        // rangeStartIndex と selectedIndex の間にある行のみ選択状態にし、
                        // 残りのうち addedByArrowKey にあるものは非選択状態に戻す
                        addedByArrowKey.forEach((row) => {
                            let idx = 0;
                            for (; idx < rows.length; ++idx) {
                                if (rows[idx] === row) {
                                    break;
                                }
                            }
                            const [start, end] = [
                                newIndex,
                                rangeStartIndex,
                            ].sort((a, b) => a - b);
                            if (idx < start || idx > end) {
                                row.classList.remove("selected");
                            }
                        });
                    } else {
                        const row = rows[selectedIndex];
                        row.classList.remove("selected");
                        addedByArrowKey.splice(addedByArrowKey.indexOf(row), 1);
                    }
                }
            }
            // 古い行のフォーカスを削除
            if (focusedIndex >= 0 && focusedIndex < rows.length) {
                rows[focusedIndex].classList.remove("focused");
            }
            if (!isCtrl) {
                // 新しい行を選択
                selectedIndex = newIndex;
                const row = rows[selectedIndex];
                row.classList.add("selected");
                if (isShift) {
                    addedByArrowKey.push(row);
                }
            }
            // 新しい行にフォーカス
            focusedIndex = newIndex;
            rows[focusedIndex].classList.add("focused");

            // 行がview内に入るようにスクロール
            if (isCtrl) {
                rows[focusedIndex].scrollIntoView({
                    behavior: "smooth",
                    block: "center",
                });
            } else {
                rows[selectedIndex].scrollIntoView({
                    behavior: "smooth",
                    block: "center",
                });
            }
        }

        ev.preventDefault();
    }

    table.addEventListener("keydown", handleKeyDown);
}

sample05/routes/web.php

<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/sample', [\App\Http\Controllers\SampleController::class, 'index']);

動作確認

以下のURLにアクセスして動作確認。

http://<your-ip-address>/sample05/public/sample

  • (テーブル内をクリックしてフォーカスをテーブルに当てた後に)
    • カーソルキーの上下を押すと選択状態が変わること
    • Ctrlキーを押しながらカーソルキーの上下を押すとフォーカス(破線)のみが移動すること
    • Shiftキーを押しながらカーソルキーの上下を押すと範囲選択ができること

辺りが確認できれば完成。

参考

Geminiに手伝ってもらってました。

share.google




以上の内容はhttps://hhelibex.hatenablog.jp/entry/2025/10/14/161633より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14