Reactで右クリックメニュー
先日書いたReact+Railsで付箋っぽいアプリを作る中で、右クリックメニューを作ってみたいなぁ、と、調べた内容と実装方法を書いていきたいと思います。
oncontextmenuのイベントハンドラを作れば良い
JavaScriptのドキュメントによれば、oncontextmenuイベントをハンドルすることで、独自のメニューが作れそうです。
実装方針
ここでは、以下の方針で実装してみることにしました。
- 右クリックした場所にメニューを表示する。
- メニューの要素は、Reactのコンポーネントとして作ってみる。
- 環境は、React+Railsで付箋っぽいアプリを作るの環境をそのまま使いましたが、多分Reactの動く環境なら、どこでも大丈夫。
実装だー
作るのは、以下の3つです。
- メニューを表示するコンポーネント(menu.js)
- 右クリックを受け付ける親コンポーネント(parent.js)
- スタイルシート(parent.css)
メニューを表示するコンポーネント
適当なメニューと"close"が選べるポップアップを表示させます。
import React from 'react';
import PropTypes from 'prop-types';
class Menu extends React.Component {
// コンストラクタ
constructor(props){
super(props); // おまじないですね。
// メニューを表示するdiv要素を参照するための変数です。
this.menuElm = null;
// イベントハンドラのバインド
this.onCloseButtonClick = this.onCloseButtonClick.bind(this);
this.onMenuItemClick = this.onMenuItemClick.bind(this);
this.onKeyUp = this.onKeyUp.bind(this);
// メニューに表示するアイテムの配列です。(適当すぎてやばい。)
this.messages = ["You", "are", "incredible"];
}
// メニュー要素を表示(visibility="visible")します。
// 親要素から、呼び出されるメソッドです。
show(clientX, clientY){
// 以下のようにstyle.top,style.leftを指定することで、好きな場所にメニューを表示できます。
this.menuElm.style.top = clientY + "px";
this.menuElm.style.left = clientX + "px";
this.menuElm.style.visibility = "visible";
// 表示したらフォーカスを割り当てます。
// (これで、keyイベントを受け付けてくれるようになります。)
this.menuElm.focus();
}
// メニューを閉じ(visibility="hidden"に変更し)ます。
close(){
this.menuElm.style.visibility = "hidden";
}
// "close"クリック時のイベントハンドラ
onCloseButtonClick() {
// close()を呼び出します。
this.close();
}
// メニューアイテムクリック時のイベントハンドラ
onMenuItemClick(event) {
// 自分自身を閉じて
this.close();
// 親要素から渡されたコールバック関数を呼び出します。
// 引数には、アイテムに表示されているテキストを渡します。
this.props.onMenuItemClick(event.target.innerHTML);
}
// エスケープキーで閉じるためのイベントハンドラです。
onKeyUp(event) {
event.preventDefault();
// 文字列で比較できるとは思いませんでした。。
if ("Escape" == event.key) {
this.close();
}
}
// レンダラー
render(){
return (
<React.Fragment>
{ /* refで要素を参照することで、styleの変更ができるようになります。 */ }
<div className="MenuBox" ref={(node) => this.menuElm = node} onKeyUp={this.onKeyUp} tabIndex="0" >
{
this.messages.map((message) =>
<div className="MenuItem" onClick={this.onMenuItemClick} key={ message }>{ message }</div>
)
}
<div className="MenuItem" onClick={this.onCloseButtonClick}>Close</div>
</div>
</React.Fragment>
);
}
}
Menu.propTypes = {
onMenuItemClick: PropTypes.func
};
export default Menu;
右クリックを受け付ける親コンポーネント
自身の領域が右クリックされたら、メニューを表示させます。
さらに、メニューで選択された内容に応じて、自身の表示内容を変化させてみました。
import React from 'react';
import Menu from './menu';
class ParentPage extends React.Component {
// コンストラクタ
constructor(props){
super(props);
// stateを初期化
this.state = {message: "Please Click Anywhere you like."}
// メニュー要素への参照を初期化(後ほどレンダラーの中でrefを割り当てます。)
this.menu = null;
// イベントハンドラのバインド
this.onContextMenu = this.onContextMenu.bind(this);
this.onMenuItemClick = this.onMenuItemClick.bind(this);
}
// 右クリックイベントハンドラ
onContextMenu(event) {
// preventDefault()を忘れると、普通の右クリックメニューが表示されますよ。
event.preventDefault();
// メニュー要素の"show()"メソッドを呼び出します。
// 引数にはマウスポインタの位置情報を渡してあげます。
this.menu.show(event.clientX, event.clientY);
}
// 右クリックメニューでメニューが選択された際にコールバックしてもらうメソッドです。
// 選択されたメニューの内容(innnerHTML)をstateに設定しています。
// (これにより、画面左上のメッセージが切り替わるはず。)
onMenuItemClick(message) {
this.setState({message: message});
}
// レンダラー
render(){
return (
<React.Fragment>
{ /* 自身の右クリックイベントハンドラをonContextMenu=で指定 */ }
<div className="ParentBox" onContextMenu={this.onContextMenu} >
{ this.state.message }
{ /* コンポーネントもrefで参照できるので、子要素のメソッドを呼び出すことが可能になります。 */ }
<Menu onMenuItemClick={this.onMenuItemClick} ref={(node) => this.menu = node} />
</div>
</React.Fragment>
);
}
}
スタイルシート
ブラウザのUIは、結局のところスタイルシートで「それっぽく見せている」だけなんですね。
何かの本にこんなことが書いてありました。
「コンピュータ使って実現しているものっていうのは、『そのように見える』だけのハリボテに過ぎない」
まさしくその通りだなぁ、と、改めて思うのでした。
// 親要素のスタイル
div.ParentBox {
font-weight: bold;
position: relative;
width: 100vw;
height: 100vh;
background-color: #FFFFFF;
border: 1px solid #000000;
}
// メニュー要素のスタイル
// position: absoluteにしないと、指定した場所に表示できないのでご注意を。
div.MenuBox {
position: absolute;
margin: 0px;
padding: 5px;
font-size: 10px;
font-weight: thin;
width: 100px;
background-color: #888888;
color: #000000;
visibility: hidden;
border-radius: 5px;
}
// focusをあてたときに周りが光らないようにしました。(気分の問題)
div.MenuBox:focus {
outline: none;
}
// 各メニューにマウスポインタが乗っかった時のスタイルです。
div.MenuItem:hover {
cursor: pointer;
background-color: #E0E0FF;
}
実験
殺風景ですが、クリックしたところにメニューが表示される感じが実現できたはずです。
メニューを選択すると、メニューに表示されていた文字列が、そのまま親ページの右肩に表示されると思います。
Escapeボタンでも、"Close"のリンクでもメニューを消すことができます。
補足
今回の内容については、DBが関係ないので、Railsの環境は正直不要で、Reactの環境だけあれば実験できます。
npmとwebpackだけで動作させた際のwebpack.config.jsの内容と、ちょっとしたソースの修正内容を記載しておきます。
ディレクトリ構成
特段特別なものはありません。
publicにindex.htmlを置いて、src配下にスクリプトとスタイルシートを置きました。
app
├── node_modules(配下は割愛)
├── package-lock.json
├── package.json
├── public
│ └── index.html
├── src
│ ├── menu.js
│ ├── parent.js
│ └── parent.scss
└── webpack.config.js
webpack.config.js
module.exports = {
mode: "development",
entry: {
app: "./src/parent.js"
},
output: {
path: __dirname + '/public/js',
filename: "[name].js"
},
devServer: {
contentBase: __dirname + '/public',
port: 8080,
publicPath: '/js/'
},
devtool: "#inline-source-map",
module: {
rules: [{
test: /\.js$/,
enforce: "pre",
exclude: /node_modules/,
loader: "eslint-loader"
},{
test: /\.css$/,
loader: ["style-loader","css-loader"]
},{
test: /\.scss$/,
loader: ["style-loader","css-loader","sass-loader"]
},{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader'
}]
}
};
public/index.html
ブラウザからアクセスするためのトップページです。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1,shrink-to-fit=no" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge, chrome=1" />
<title>React App</title>
</head>
<body>
<div id="root"></div>
<script type="text/javascript" src="js/app.js" charset="utf-8"></script>
</body>
</html>
src/parent.js
最後に、メインエントリポイントになるparent.jsの修正部分を書いておきます。
明示的にimportしないとスタイルが読み込まれなかったので、その対処と、
ReactDom.renderの呼び出しを追加しました。
// 1,2はファイルの先頭(import文の並び)に追加
// 1. ReactDomの追加
import ReactDom from 'react-dom';
// 2. スタイルの参照を追加
import './parent.scss';
// : (中略)
// 3はファイルの最後に追加
// 3. レンダラーの呼び出し
ReactDom.render(
<ParentPage />,
document.getElementById('root')
);