右クリック コンテキストメニュー(Context Menu)— テーブル行操作

通知・オーバーレイ 初級

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

コンテキストメニュー(右クリックメニュー)は、テーブルの行などを右クリックしたときに表示するカスタムポップアップメニューです。 ブラウザ標準の右クリックメニューを preventDefault() で無効化し、クリック座標に独自メニューをポップアップ表示します。 社員一覧・タスク管理・ファイル管理など、行単位で「編集・複製・削除」などの操作を行う業務アプリに向いています。

  • 編集 — 対象行の氏名をトースト通知で表示(「〇〇 を編集します」)
  • 複製 — 対象行をテーブル末尾に追加し、トースト通知で確認
  • 削除 — 対象行を即時削除し、トースト通知で確認
  • 簡易トースト — 操作フィードバック用のトースト通知を内包。外部ライブラリ不要

実装のポイント・注意点

テーブルの各 trcontextmenu イベントを登録し、e.preventDefault() でブラウザ標準の右クリックメニューを抑制します。 メニューは position: fixed で配置し、e.clientX / e.clientY(ビューポート基準の座標)をそのまま style.left / style.top に渡します。

画面端補正は、メニューを表示したあとに getBoundingClientRect() で実際のサイズを取得し、 右端・下端からはみ出す場合にクリック座標からメニュー幅・高さ分だけ引いた位置に移動させる方法で実装しています。

メニューを閉じるトリガーは3種類です。外クリックdocumentclick イベントでメニュー外判定)、 Escキーkeydown イベント)、スクロールscroll イベントをキャプチャフェーズで監視)。 いずれも確実に動作するよう設計しています。

メニュー項目クリック後のDOM操作(行の追加・削除)はすべて createElement + textContent で行っています。 innerHTML に変数を渡すと XSS の原因になるため、動的テキストには必ず textContent を使います。

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

デモ

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>右クリック コンテキストメニュー サンプル</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<div class="cm-wrap">
  <table class="cm-table" id="js-cm-table">
    <thead>
      <tr>
        <th>ID</th>
        <th>氏名</th>
        <th>部署</th>
        <th>役職</th>
      </tr>
    </thead>
    <tbody id="js-cm-tbody"></tbody>
  </table>
  <p class="cm-hint">行を右クリックするとメニューが表示されます</p>
</div>

<!-- コンテキストメニュー(position: fixed のため body 直下に置く) -->
<ul class="cm-menu" id="js-cm-menu" role="menu" aria-label="行の操作メニュー">
  <li class="cm-menu-item" id="js-cm-edit" role="menuitem">編集</li>
  <li class="cm-menu-item" id="js-cm-copy" role="menuitem">複製</li>
  <li class="cm-menu-sep" role="separator"></li>
  <li class="cm-menu-item cm-menu-item--danger" id="js-cm-delete" role="menuitem">削除</li>
</ul>

<!-- トースト通知(position: fixed のため body 直下に置く) -->
<div class="cm-toast" id="js-cm-toast" role="status" aria-live="polite"></div>

<script src="./script.js"></script>
</body>
</html>
:root {
  --cm-border: #E2E8F0;
  --cm-danger: #E53E3E;
  --cm-danger-bg: #FFF5F5;
  --cm-hover-bg: #EEF4FF;
  --cm-active-bg: #EBF4FF;
}

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

body {
  font-family: sans-serif;
  background: #F4F6F9;
  margin: 0;
  padding: 24px;
}

/* テーブル */
.cm-wrap {
  background: #fff;
  border-radius: 8px;
  overflow: hidden;
  overflow-x: auto;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
  max-width: 600px;
}

.cm-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
  min-width: 360px;
}

.cm-table th {
  background: #F4F6F9;
  color: #4A5568;
  font-weight: 600;
  padding: 10px 14px;
  text-align: left;
  border-bottom: 2px solid var(--cm-border);
  white-space: nowrap;
}

.cm-table td {
  padding: 10px 14px;
  border-bottom: 1px solid var(--cm-border);
  color: #2D3748;
}

.cm-table tbody tr {
  cursor: default;
  transition: background 0.1s;
}

.cm-table tbody tr:hover { background: #F8FAFC; }

/* 右クリックされた行のハイライト */
.cm-table tbody tr.is-active { background: var(--cm-active-bg); }

.cm-hint {
  font-size: 12px;
  color: #94A3B8;
  text-align: center;
  padding: 8px;
  margin: 0;
}

/* コンテキストメニュー */
.cm-menu {
  display: none;
  position: fixed;
  z-index: 9999;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
  border: 1px solid var(--cm-border);
  min-width: 140px;
  list-style: none;
  margin: 0;
  padding: 4px 0;
}

.cm-menu-item {
  padding: 9px 16px;
  font-size: 14px;
  color: #2D3748;
  cursor: pointer;
  transition: background 0.1s;
  user-select: none;
}

.cm-menu-item:hover { background: var(--cm-hover-bg); }

.cm-menu-item--danger { color: var(--cm-danger); }

.cm-menu-item--danger:hover { background: var(--cm-danger-bg); }

.cm-menu-sep {
  border: none;
  border-top: 1px solid var(--cm-border);
  margin: 4px 0;
}

/* トースト通知 */
.cm-toast {
  position: fixed;
  bottom: 32px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 10000;
  background: #1A202C;
  color: #fff;
  font-size: 14px;
  padding: 10px 20px;
  border-radius: 8px;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s;
}

.cm-toast.is-show { opacity: 1; }
// テーブルデータ
var ROWS_INITIAL = [
  { id: 1, name: '田中 太郎', dept: '営業部', role: 'マネージャー' },
  { id: 2, name: '鈴木 花子', dept: '開発部', role: 'エンジニア' },
  { id: 3, name: '佐藤 次郎', dept: '総務部', role: 'スタッフ' },
  { id: 4, name: '山田 恵子', dept: '営業部', role: 'リーダー' },
  { id: 5, name: '中村 健一', dept: '開発部', role: 'エンジニア' },
];

var rows = JSON.parse(JSON.stringify(ROWS_INITIAL));
var nextId = 6;
var targetRow = null; // 右クリックされた行のデータ

// =========================================
// テーブル描画
// =========================================
function renderTable() {
  var tbody = document.getElementById('js-cm-tbody');
  tbody.textContent = '';

  rows.forEach(function(row) {
    var tr = document.createElement('tr');
    tr.dataset.id = row.id;

    // ID・氏名・部署・役職のセルを生成(textContent でXSS対策)
    ['id', 'name', 'dept', 'role'].forEach(function(key) {
      var td = document.createElement('td');
      td.textContent = row[key];
      tr.appendChild(td);
    });

    // 右クリックでコンテキストメニューを表示
    tr.addEventListener('contextmenu', function(e) {
      e.preventDefault();
      targetRow = row;
      showMenu(e, this);
    });

    tbody.appendChild(tr);
  });
}

// =========================================
// コンテキストメニューの表示
// =========================================
function showMenu(e, rowEl) {
  var menu = document.getElementById('js-cm-menu');

  // カーソル位置に仮配置
  menu.style.left = e.clientX + 'px';
  menu.style.top  = e.clientY + 'px';
  menu.style.display = 'block';

  // 画面端補正(右端・下端からはみ出す場合に反対側へ移動)
  var rect = menu.getBoundingClientRect();
  if (rect.right  > window.innerWidth)  menu.style.left = (e.clientX - rect.width)  + 'px';
  if (rect.bottom > window.innerHeight) menu.style.top  = (e.clientY - rect.height) + 'px';

  // 対象行をハイライト
  document.querySelectorAll('#js-cm-tbody tr').forEach(function(tr) {
    tr.classList.remove('is-active');
  });
  rowEl.classList.add('is-active');
}

// =========================================
// コンテキストメニューを閉じる
// =========================================
function hideMenu() {
  var menu = document.getElementById('js-cm-menu');
  menu.style.display = 'none';

  document.querySelectorAll('#js-cm-tbody tr').forEach(function(tr) {
    tr.classList.remove('is-active');
  });

  targetRow = null;
}

// 外クリックで閉じる
document.addEventListener('click', function(e) {
  var menu = document.getElementById('js-cm-menu');
  if (menu.style.display === 'block' && !menu.contains(e.target)) {
    hideMenu();
  }
});

// Escキーで閉じる
document.addEventListener('keydown', function(e) {
  if (e.key === 'Escape') hideMenu();
});

// スクロールで閉じる
document.addEventListener('scroll', hideMenu, true);

// =========================================
// メニュー項目のクリック処理
// =========================================
document.getElementById('js-cm-edit').addEventListener('click', function() {
  if (!targetRow) return;
  showToast(targetRow.name + ' を編集します');
  hideMenu();
});

document.getElementById('js-cm-copy').addEventListener('click', function() {
  if (!targetRow) return;
  var sourceName = targetRow.name; // hideMenu() 前にキャプチャ
  var copied = {
    id: nextId++,
    name: sourceName + '(コピー)',
    dept: targetRow.dept,
    role: targetRow.role,
  };
  rows.push(copied);
  renderTable();
  showToast(sourceName + ' を複製しました');
  hideMenu();
});

document.getElementById('js-cm-delete').addEventListener('click', function() {
  if (!targetRow) return;
  var deletedName = targetRow.name; // hideMenu() 前にキャプチャ
  var deletedId   = targetRow.id;
  rows = rows.filter(function(r) { return r.id !== deletedId; });
  renderTable();
  showToast(deletedName + ' を削除しました');
  hideMenu();
});

// =========================================
// トースト通知
// =========================================
function showToast(msg) {
  var toast = document.getElementById('js-cm-toast');
  toast.textContent = msg;
  toast.classList.add('is-show');
  clearTimeout(toast._timer);
  toast._timer = setTimeout(function() {
    toast.classList.remove('is-show');
  }, 2500);
}

// 初期描画
renderTable();

AI用プロンプト

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

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

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

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

# 右クリック コンテキストメニュー 作成依頼

## 概要
テーブルの行を右クリックすると、編集・複製・削除のカスタムコンテキストメニューが表示されるUIを実装してください。

## 要件
- テーブルデータ(ID・氏名・部署・役職)をJS配列で保持し、テーブルとして描画する
- 行を右クリックするとブラウザ標準メニューを抑制し、カーソル位置にカスタムメニューを表示する
- メニュー項目は「編集」「複製」「削除」の3つ(削除は区切り線の下・赤文字)
- 編集: トースト通知で「[氏名] を編集します」と表示
- 複製: 対象行をテーブル末尾に追加(氏名に「(コピー)」を付与)し、トースト通知を表示
- 削除: 対象行を削除し、トースト通知を表示
- メニューは外クリック・Escキー・スクロールで閉じる
- メニューが画面右端・下端からはみ出す場合は自動的に位置を補正する
- トースト通知を簡易実装する(外部ライブラリ不要)

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

## 動作詳細
各 tr に contextmenu イベントを登録し preventDefault() でブラウザ標準メニューを抑制する。
メニューは position: fixed で表示し、event.clientX / clientY を起点にする。
表示後に getBoundingClientRect() でサイズを取得し、画面端をはみ出す場合は座標を補正する。
document への click・keydown(Escape)・scroll イベントでメニューを閉じる。
テーブル描画・行の追加削除はすべて createElement + textContent で行い、innerHTML に変数を渡さない。
トーストは固定位置の div を opacity で表示/非表示し、2.5秒後に自動で消す。

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