症状:自作の右クリックメニューでハマりやすい3つの落とし穴
contextmenu イベントを preventDefault() して独自のメニューを出す実装自体は、それほど難しくありません。ですが「動いた」で止めてしまうと、実務では次の3つの落とし穴にほぼ必ず引っかかります。
落とし穴1:画面端でメニューが切れる
クリック座標にそのままメニューを出すと、画面の右端・下端近くで右クリックしたときにメニューが画面外にはみ出し、選択肢の一部が見えなくなる。
落とし穴2:閉じるタイミングが不完全
外側をクリックしたときだけ閉じる実装にしがちだが、Escキーやスクロールでも閉じないと、メニューが画面に貼り付いたまま残る。
落とし穴3:モバイルで長押しメニューと衝突する
タッチデバイスの長押しにはOS標準のテキスト選択・共有メニューが割り当てられており、PC向けの右クリックメニューをそのまま持ち込むと動作が競合する。
以下、1つずつ症状と直し方を見ていきます。実装全体は 右クリック コンテキストメニュー の事例ページで動くコードとして確認できます。
落とし穴1:画面端でメニューが切れる
下のデモは、点線の枠を「画面」に見立てた小さなステージです。右下に置いた青いボタンを右クリックすると、その位置を起点にメニューが開きます。左は座標をそのまま使った実装、右は枠からはみ出さないよう位置を補正した実装です。
クリック座標にそのままメニューを表示します。ボタンが右下にあるため、メニューが枠の外にはみ出して切れます。
表示後にメニュー自身のサイズを測り、枠からはみ出す場合はクリック位置から逆方向にずらして表示します。
// 切れる:クリック座標にそのまま表示する menu.style.display = 'block'; menu.style.left = e.clientX + 'px'; menu.style.top = e.clientY + 'px'; // 直す:表示後に実サイズを測り、はみ出す方向を逆に補正する menu.style.display = 'block'; menu.style.left = e.clientX + 'px'; menu.style.top = e.clientY + 'px'; var rect = menu.getBoundingClientRect(); var left = e.clientX; var top = e.clientY; if (rect.right > window.innerWidth) left = e.clientX - rect.width; if (rect.bottom > window.innerHeight) top = e.clientY - rect.height; // 反転させた結果、今度は逆側(上端・左端)からはみ出すことがあるので最後にクランプする left = Math.max(0, Math.min(left, window.innerWidth - rect.width)); top = Math.max(0, Math.min(top, window.innerHeight - rect.height)); menu.style.left = left + 'px'; menu.style.top = top + 'px';
display: none のままではサイズが測れないため、先に一度表示してから getBoundingClientRect() で実サイズを取得するのが順番のポイントです。右端・下端からはみ出す場合は反対方向にずらしますが、それだけだと今度は上端や左端からはみ出す組み合わせが起こりえます(クリック位置が画面の隅に近く、メニューがそこそこ大きいときに実際に起きます)。最後に Math.max / Math.min で画面の内側に収まる範囲へ丸めておくと、どの角で右クリックしても崩れません。
落とし穴2:閉じるタイミングが不完全
右クリックメニューを閉じる操作は、外側クリックだけでは足りません。下のデモで、行を右クリックしてメニューを開いたまま、Escキーを押したり、このページ自体をスクロールしたりしてみてください。
外側クリックでは閉じますが、Escキーやスクロールでは閉じずに残り続けます。
| 氏名 | 部署 |
|---|---|
| 田中 太郎 | 営業部 |
| 鈴木 花子 | 開発部 |
行を右クリック → メニューを開いたままEsc・スクロールを試す
外側クリック・Escキー・スクロールのどの操作でも確実に閉じます。
| 氏名 | 部署 |
|---|---|
| 佐藤 次郎 | 総務部 |
| 山田 恵子 | 営業部 |
行を右クリック → メニューを開いたままEsc・スクロールを試す
// 不完全:外側クリックだけしか見ていない
document.addEventListener('click', function (e) {
if (!menu.contains(e.target)) hideMenu();
});
// 直す:外側クリックに加えて Esc とスクロールでも閉じる
document.addEventListener('click', function (e) {
if (!menu.contains(e.target)) hideMenu();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') hideMenu();
});
document.addEventListener('scroll', hideMenu, { passive: true });
スクロールで閉じる処理を忘れると、position: fixed のメニューは画面上の同じ位置に貼り付いたまま、下の内容だけが動いていく見た目になります。行の位置とメニューの中身がずれた状態で「削除」を押されると、意図しない行を操作してしまう事故にもつながります。
落とし穴3:モバイルで長押しメニューと衝突する
PC向けの contextmenu イベントと、タッチデバイスの長押しは別物です。iOS SafariやAndroid Chromeの長押しには、テキスト選択や画像の保存・共有といったOS標準のメニューがあらかじめ割り当てられています。素朴に「PCでもモバイルでも同じ実装で右クリック風メニューを出そう」とすると、この標準の挙動とかち合い、狙った通りに開かなかったり、意図しない選択メニューが先に出てしまったりします。ブラウザやOSのバージョンによって細部の挙動は変わりうるため、断定はせず「衝突しうる」という前提で設計するのが安全です。
この記事では衝突の起き方を再現する動くデモは作っていません。実務での対策としては、長押しでの検知を頑張るより、モバイル幅ではそもそも右クリックメニューを模倣しない設計が現実的です。行の末尾に常設の「⋮」ボタンを置き、タップでメニューを開く形にすれば、長押しとの衝突を避けつつ同じ操作(編集・複製・削除)を提供できます。
// モバイル幅では長押し検知をせず、常設ボタンでメニューを開く
var isCoarsePointer = window.matchMedia('(pointer: coarse)').matches;
row.addEventListener('contextmenu', function (e) {
if (isCoarsePointer) return; // タッチ環境ではブラウザ標準の挙動に任せる
e.preventDefault();
showMenu(e, rowData);
});
// 行末に常設した「⋮」ボタン側は環境を問わずタップ/クリックで開く
kebabButton.addEventListener('click', function (e) {
e.stopPropagation();
showMenu(e, rowData);
});
pointer: coarse は「指のような精度の低い入力デバイスか」を判定するメディア特性で、タッチ操作の判定によく使われます。ただしタッチ対応ノートPCのようなハイブリッド端末もあるため、これだけで完全に環境を切り分けられるわけではない点は留意してください。メニュー自体の実装(位置補正・開閉制御)はPC版と共通化でき、開き方のトリガーだけを分ければ十分です。ボタンからメニューを開く実装は、ポップオーバー — 行メニューをPopover APIで実装するの事例が参考になります。
まとめ
右クリックメニューを自作するときは、次の3点を最初から設計に組み込んでおくと、あとから作り直す手間を避けられます。
| 落とし穴 | 原因 | 対策 |
|---|---|---|
| 画面端で切れる | クリック座標にそのまま表示している | 表示後に実サイズを測り、はみ出す方向を逆に補正する |
| 閉じるタイミングが不完全 | 外側クリックしか見ていない | 外側クリック・Esc・スクロールの3つで閉じる |
| モバイルで衝突する | 長押しとcontextmenuを同じ実装で扱おうとする | モバイル幅では常設の「⋮」ボタンからメニューを開く |
この3点を押さえた実装は、右クリック コンテキストメニューの事例ページでテーブル行の編集・複製・削除メニューとして動くコードごと確認できます。コピペしてそのまま使える形になっています。
関連するUI事例
右クリックメニューと組み合わせて使うことが多いUIの実装例です。