まだプログラマーですが何か?

プログラマーネタとアスリートネタ中心。たまに作成したウェブサービス関連の話も http://twitter.com/dotnsf

タグ:modal

Bootstrap はバージョン5のβ版もリリースされ(2021/01/28 時点)、グリッドを使ったページスタイルなどでは今でも人気の UI フレームワームだと思っています:
2021012803


自分自身では、この Bootstrap の Modal 機能をよく使っています。いわゆるモーダルダイアログを実現するもので、別ページに飛ばすほどではない(すぐ元のページ内容に戻るのが容易な)画面をモーダルで表示し、モーダルが消えれば元の画面に戻ります。この間の画面遷移も実質ありません。

今回チャレンジしたのは「モーダルのモジュール化」です。具体的には以下の2点を実現する方法を考えました:

  1. テンプレートエンジンを使うなどしてモーダルダイアログを実現する HTML 部分を切り出し、複数の HTML ページから同じモーダルダイアログを呼び出せるようにする
  2. 呼び出し元の HTML ごとにモーダルダイアログ終了後の処理を変える

1. はテンプレートエンジン(例えば今回は Node.js と EJS を使うので EJS で説明します)の機能を使って HTML テンプレートを分割し、モーダルダイアログに関わる部分だけを別モジュールにする、というものです。これによって異なる呼び出し元(例えば page1 と page2)から同じモーダルダイアログ(mymodal)を利用できるようにします。
2021012808


2. は 1. の更なる応用です。例えば mymodal の中にテキストフィールドが1つあって、そこに任意の文字列を入力できるものとします。
2021012803


そして page1 から mymodal を呼び出した場合、mymodal が終了して元の page2 に戻ったら、mymodal に入力された文字列が "http" で始まる URL 文字列だった場合はその URL へジャンプ、URL 文字列でなかった場合は何もしない、ものとします。
2021012804
  ↓
2021012805


一方、page2 から mymodal を呼び出した場合、mymodal が終了して元の page2 に戻ったら、mymodal に入力された文字列をそのまま画面に表示するという条件分岐を行うものとします。
2021012806
  ↓
2021012807


このように、異なる呼び出し元から同じモーダルダイアログを呼び出すのですが、モーダルダイアログ終了後の挙動は呼び出し元ごとに変えたい、というのが 2 の要件です。
2021012809

2021012810


これらが難しい理由は2つあって、(1)まずモーダルダイアログは $('#modal').modal(); で起動するのですが、モーダルダイアログ自体が終了時に値を返すわけではないので、返り値を受け取ってその値を元に次の処理に移れるわけではない点。(2)そしてモーダルダイアログ終了時の処理を予め記述しておくことはできるのですが、その方法だと異なる呼び出し元ごとに終了後の挙動を変えることができないという点です。

これらの技術的挑戦は(2)でコールバック関数を使うことで((1)とまとめて)解決することができました。つまりモーダルダイアログが終了する際に呼び出し元ごとのコールバック関数が呼ばれるようにしておくことでモーダル終了後の処理を分離することができるようになり、分離できてしまえば(1)は普通に実現できるようになるのでした。


では実際のコードを見てみます。まず Node.js 側ですが、GET /page1 で page1.ejs を、GET /page2 で page2.ejs を呼び出すようなルーティングを定義しています:
var express = require( 'express' ),
    ejs = require( 'ejs' ),
    app = express();

app.use( express.Router() );

app.set( 'views', __dirname + '/views' );
app.set( 'view engine', 'ejs' );

app.get( '/page1', async function( req, res ){
  res.render( 'page1', {} );
});

app.get( '/page2', async function( req, res ){
  res.render( 'page2', {} );
});


var port = process.env.PORT || 8080;
app.listen( port );
console.log( "server starting on " + port + " ..." );



次に page1.ejs 側ではモーダルダイアログを呼び出す画面まではこのテンプレート内で用意しますが、EJS の include 命令を使ってモーダルダイアログ mymodal.ejs を読み込んでいます。
<body>

<div class="container">
  <h1>Page1</h1>
</div>

<div class="container">
<button class="btn btn-primary" onClick="myOpen();">モーダルダイアログ</button>
</div>

<%- include('./mymodal', {}) %>

</body>


page2.ejs 側も同様です。自分たちの画面は自身のテンプレート内に記述して、共通で使うモーダルダイアログ部分だけを外部読み込みする形のテンプレートです。
<body>

<div class="container">
  <h1>Page2</h1>
</div>

<div class="container">
<button class="btn btn-primary" onClick="myOpen();">モーダルダイアログ</button>
</div>

<%- include('./mymodal', {}) %>

</body>


page1 および page2 から(ejs の include 命令で埋め込まれて)呼び出される側の mymodal.ejs です。Bootstrap の Modal による画面定義が行われていますが、今回はシンプルに id="text" のテキストフィールドを1つ配置しているだけです。ここまでで page1 および page2 の見た目に関する部分(HTML および CSS)は準備できました。
<div class="modal bd-example-modal-lg fade" id="myModal" tabindex="-1" role="dialog" aria-labbelledby="myModal" aria-hidden="true">
  <div class="modal-dialog modal-dialog-centered modal-lg">
    <div class="modal-content">
      <div class="modal-header">
        <h4 class="modal-title" id="myModalLabel">共有ダイアログ</h4>
      </div>
      <div class="modal-body" id="mymodal-body">
        <div>
          <input type="text" name="text" class="form-control" id="text"/>
        </div>
      </div>
      <div class="modal-footer btn-center">
        <button type="button" class="btn modal_button" data-toggle="modal" onClick="myModalClose();">OK</button>
      </div>
    </div>
  </div>
</div>


次にそれぞれの挙動に関する部分を見てみます。page1 の「モーダルダイアログを呼び出すボタン」をクリックすると myOpen() という関数が実行されるように定義されています。
<button class="btn btn-primary" onClick="myOpen();">モーダルダイアログ</button>


この関数は page1.ejs 内に定義されていて、その中では modalCallback という関数をパラメータにして myModalOpen() 関数が実行されています。
<script>
function myOpen(){
  myModalOpen( modalCallback );
}

function modalCallback( text ){
  if( text.startsWith( 'http' ) ){
    window.location.href = text;
  }else{
    //. Do nothing ?
  }
}
</script>


この myModalOpen() 関数は mymodal.ejs 内に定義されていて、その内容は後述します。また modalCallback 関数は myOpen のすぐ下に定義されています。実はこれがコールバック関数になっていて、mymodal.ejs の表示が終了したタイミングで、テキストフィールドに入力された値をパラメータにして実行されます。page1 ではこの値を調べて、"http" という文字で始まっていた場合は URL とみなし、その URL へ移動するような処理が記述されています。


同様にして、page2 の同部分ではコールバックされた関数内で alert() を使って、テキストフィールドに入力された値をそのまま表示するような処理が記述されています。
<script>
function myOpen(){
  myModalOpen( modalCallback );
}

function modalCallback( text ){
  alert( text );
}
</script>


最後に mymodal.ejs 内の JavaScript を見てみます。page1, page2 からモーダルダイアログ表示時に呼び出される myModalOpen() 関数内で、まずコールバック関数を変数に退避してから $('#myModal').modal() を実行してモーダルダイアログを画面に表示しています。
モーダルダイアログの終了ボタンがクリックされると myModalClose() 関数が実行されます。この関数内ではモーダルを非表示にする(戻す)ための処理が行われ、その後テキストフィールドに入力された値を取り出し、その値をパラメータに退避していたコールバック関数が実行されます。
<script>
var __callback = null;

function myModalOpen( func ){
  __callback = func;
  $('#myModal').modal();
}

function myModalClose(){
  $('body').removeClass( 'modal-open' );
  $('.modal-backdrop').remove();
  $('#myModal').modal( 'hide' );

  var text = $('#text').val();

  __callback( text );
}
</script>

こうすることでモーダルダイアログ終了時に、呼び出し元( page1 や page2 )で myModalOpen() 実行時に指定されたコールバック関数へ処理を戻すことができるようになります。またその際にダイアログ内のテキストフィールドの値も引き渡すことができるので、ダイアログで指定した値を使ってそれぞれの処理を行うことが可能になりました。


もう少し綺麗にモジュール化できるかもしれませんが、おおまかな考え方はこんな感じで実現できました。ソースコードは以下で公開しておきます:
https://github.com/dotnsf/bootstrap_modals



Bootstrap を使うとモーダルダイアログを簡単に作ることができます。


モーダルダイアログは親画面の中で表示されるダイアログボックス画面のことで、「このダイアログボックスを閉じるまで親画面や他の画面に移動することができない」という特徴を持っています。利用者に質問を促して「はい」か「いいえ」のボタンを押させる、といった時の、どちらかを押すまで他の処理ができなくなる(どちらかを押す前に他の処理をされては困る)場合などに使われます。

具体的にはこんな感じで実装できます:
<html>
<head>
<meta charset="utf8"/>
<title>Bootstrap モーダルから別のモーダルへ</title>
<script type="text/javascript" src="https://code.jquery.com/jquery-2.0.3.min.js"></script>
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" rel="stylesheet"/>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js"></script>
</head>
<body>
  
  <nav class="navbar">
    <a href="#" class="navbar-brand">Bootstrap モーダルから別のモーダルへ</a>
  </nav>

  <!-- Button trigger modal -->
  <div class="container">
    <a href="#" class="btn btn-primary" data-toggle="modal" data-target="#exampleModal1">モーダル1</a><br/>
    <a href="#" class="btn btn-success" data-toggle="modal" data-target="#exampleModal2">モーダル2</a>
  </div>

  <!-- Modal1 -->
  <div class="modal fade" id="exampleModal1" tabindex="-1" role="dialog" aria-labelledby="exampleModal1Label" aria-hidden="true">
    <div class="modal-dialog" role="document">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="exampleModal1Label">モーダル1</h5>
          <button type="button" class="close" data-dismiss="modal" aria-label="Close">
            <span aria-hidden="true">×</span>
          </button>
        </div>
        <div class="modal-body">
          <p>1番目のモーダル</p>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
        </div>
      </div>
    </div>
  </div>

  <!-- Modal2 -->
  <div class="modal fade" id="exampleModal2" tabindex="-1" role="dialog" aria-labelledby="exampleModal2Label" aria-hidden="true">
    <div class="modal-dialog" role="document">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="exampleModal2Label">モーダル2</h5>
          <button type="button" class="close" data-dismiss="modal" aria-label="Close">
            <span aria-hidden="true">×</span>
          </button>
        </div>
        <div class="modal-body">
          <p>2番目のモーダル</p>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
        </div>
      </div>
    </div>
  </div>
</body>
</html>

画面内には2つのモーダルダイアログ(便宜上、「モーダル1」と「モーダル2」と呼ぶことにします)が定義されていて、最初はどちらも非表示状態になっています。初期画面では2つのボタンがあり、それぞれモーダル1、モーダル2を表示するためのボタンになっています。モーダル1を表示している間、元の親画面は薄暗くグレーアウトされ、モーダル1を閉じるまではこの画面上に戻って処理を行うことはできません。モーダル2も同様です。HTML を見ていただくとわかるのですが、特別に JavaScript などを定義することもなく、Bootstrap の標準機能の一部として定義されているので、CSS の指定だけで実現できることがわかります。またモーダルダイアログ内を HTML で簡単&自由度高くカスタマイズできる点も便利です:



では「モーダル1を表示し、モーダル1を消すと同時にモーダル2を呼び出す」ということはできるでしょうか? 結論としてはできるのですが、少し JavaScript を使う必要があります。

上記を少し改良しました。モーダル1を閉じる際に「普通に閉じる」ボタンと「閉じてモーダル2を呼び出す」ボタンを用意しています。そして後者がクリックされた時に以下の JavaScript が呼び出されるように定義しました:
<html>
<head>
<meta charset="utf8"/>
<title>Bootstrap モーダルから別のモーダルへ</title>
<script type="text/javascript" src="https://code.jquery.com/jquery-2.0.3.min.js"></script>
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" rel="stylesheet"/>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js"></script>
<script>
function changeModal(){
  $('body').removeClass( 'modal-open' );
  $('.modal-backdrop').remove();
  $('#exampleModal1').modal( 'hide' );
  
  $('#exampleModal2').modal();
}
</script>
</head>
<body>
  
  <nav class="navbar">
    <a href="#" class="navbar-brand">Bootstrap モーダルから別のモーダルへ</a>
  </nav>

  <!-- Button trigger modal -->
  <div class="container">
    <a href="#" class="btn btn-primary" data-toggle="modal" data-target="#exampleModal1">モーダル1</a><br/>
    <a href="#" class="btn btn-success" data-toggle="modal" data-target="#exampleModal2">モーダル2</a>
  </div>

  <!-- Modal1 -->
  <div class="modal fade" id="exampleModal1" tabindex="-1" role="dialog" aria-labelledby="exampleModal1Label" aria-hidden="true">
    <div class="modal-dialog" role="document">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="exampleModal1Label">モーダル1</h5>
          <button type="button" class="close" data-dismiss="modal" aria-label="Close">
            <span aria-hidden="true">×</span>
          </button>
        </div>
        <div class="modal-body">
          <p>1番目のモーダル</p>
          <a href="#" class="btn btn-success" onClick="changeModal()">モーダル2へ</a>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
        </div>
      </div>
    </div>
  </div>

  <!-- Modal2 -->
  <div class="modal fade" id="exampleModal2" tabindex="-1" role="dialog" aria-labelledby="exampleModal2Label" aria-hidden="true">
    <div class="modal-dialog" role="document">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="exampleModal2Label">モーダル2</h5>
          <button type="button" class="close" data-dismiss="modal" aria-label="Close">
            <span aria-hidden="true">×</span>
          </button>
        </div>
        <div class="modal-body">
          <p>2番目のモーダル</p>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
        </div>
      </div>
    </div>
  </div>
</body>
</html>

こうすることでモーダル1を閉じて、後始末の処理も行い、その上でモーダル2を表示する、という一連の処理をすべて JavaScript で行うようにしています。これでモーダルダイアログから別のモーダルダイアログを表示することができるようになります。



このページのトップヘ