Top

Commentary

Modal Dialogs

モーダル

はじめに #

モーダルダイアログはUI要素としてウェブデザインで非常によく使われます。よって簡単にモーダルダイアログが作れることが開発者としては重要です。

HTMLにはdialogタグも用意されていますので、表示自体は難しくありません。ただし内容をサーバからロードする場合はネットワーク遅延への対応などが発生します。これにうまく対応するか否かでUI/UXに差が出てきます。

下記ではUXおよび実装の簡単さを重視しつつ、モーダルダイアログの作り方を複数検討します。Hotwireに加え、Next.jsのApp RouterとPages Routerについてもモーダルの出し方を検討し、比較します。またdialogタグはまだ新しいので、従来のHTML/CSSの組み合わせでモーダルダイアログを作ります。

モーダルダイアログ (Hotwire Stimulusバージョン)

MPAを使った方法 #

モーダルはAJAXを使ったインタラクティブUIの代表例ですが、実はMPAでも実装できます。AJAXもfetchも使うことなく(完璧ではないものの)、実用的なUI/UXが非常に簡単に実現できます。

ポイントはページ遷移タブメニュー詳細パネルと同じで、画面全体は更新しつつ、一部しか変更されていないように見せます。

コード #

エンドポイント

pages/api/hotwire/modal_mpa/index.ts

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<string>,
) {
  const users: User[] = await allUsers()

  const id = req.query.userId
  let userDetail: User & UserDetail | null = null

  if (id) {
    if (!(typeof id === 'string')) throw "Bad request"
    userDetail = await findUserWithDetails(parseInt(id))
  }

  const resultText = render("modal_mpa/index.ejs",
    {users, userDetail}
  )

  res.appendHeader("Content-Type", "text/html")
    .status(200)
    .send(resultText)
}
  • このエンドポイントは/api/hotwire/modal_mpaもしくは/api/hotwire/modal_mpa?userId=1のようなURLでアクセスされることを想定しています。最後の?userId=1はモーダルを表示するときの、モーダルの中に表示するUserのIDになります
  • allUsers()を使って、users変数にすべてのUserの情報を入れます
  • req.query.userIdでURLの?userId=1の部分からuserIdを取得し、findUserWithDetails()を使って詳細情報(userDetail)を取得します。これはモーダルの中に表示する情報です
  • User一覧に表示する情報usersとモーダルに表示する情報userDetailの双方を、ejsテンプレートのmodal_mpa/index.ejsに送ります

モーダルの表示

templates/modal_mpa/index.ejs

  ...
  <% if (userDetail) { %>
    <%- include("_modal.ejs", {userDetail}) %>
  <% } %>
  ...
  • pages/api/hotwire/modal_mpa/index.tsエンドポイントから送られるuserDetailが指定されていれば、_modal.ejsパーシャルを表示します
  • pages/api/hotwire/modal_mpa/index.tsエンドポイントから送られてくるuserDetailnullの場合は、_modal.ejsパーシャルを表示しません(モーダルを表示しないということ)

モーダルを閉じるボタン

templates/modal_mpa/_modal.ejs

<a class="p-1 text-sm w-auto hover:cursor-pointer translate-x-2 active:scale-125"
    href="/api/hotwire/modal_mpa">
  • aタグを使用して、?userId=*が無いページをリクエストしています。つまりモーダルがないページです
  • モーダルがないページを表示することにより、モーダルが閉じたかのように見せかけます

UI/UX #

MPAで作ったデモを用意していますので、ご覧ください。遅延(Delay)を設定できますので、300msの場合と2000msの場合を比較してください。

  • サーバのレスポンスが十分に速い場合(300msの場合)は快適です。
  • 遅延を2000msに設定した場合、すぐにはモーダルを表示したり、モーダルを閉じたりするのに時間がかかってしまいます。しかしブラウザのプログレスバーが表示されますので、ユーザはフィードバックが得られますので安心できます
  • モーダルを表示したとき、背景の画面は常に一番上までスクロールします。スクロール位置が維持されません。短いページであれば問題ありませんが、長いページの場合は不便です

モーダルダイアログ (MPA バージョン)

まとめ #

  • MPAなのでコードは非常にシンプルです
  • UI/UX的には、フィードバックが多少わかりにくい点およびスクロール位置がリセットされてしまうのが課題です。ただしサーバレスポンスが300ms程度でかつ短いページであれば問題がないレベルです

Turbo Frames: カスタムJavaScriptなし#

次はHotwire/Turbo Framesを使って、カスタムのJavaScriptを書かずにモーダルを出す方法です。「Hotwireを使うとJavaScriptなしでモーダルダイアログが作れる」という話はよく聞きますが、その時の手法です

カスタムJavaScriptなしのデモはこちらに用意しています。またコードはGitHubにあります。

サーバから動的な内容をモーダルを表示する際は、通常は2つステップが必要です。

  1. モーダルを表示する (カスタムJavaScriptで行う: HotwireならStimulus)
  2. モーダルのコンテンツをサーバにリクエストし、結果をモーダル内に表示する (AJAX/fetchで行う: HotwireならTurbo Framesがすべての処理をしてくれます)

しかし、サーバからのレスポンスと一緒にモーダルの「枠」も同時に送り返せば、モーダルを開くJavaScriptが必要なステップ1.は省略可能になります。先のMPAの場合と同じですが、この仕組みを引き継いでいるのがこの方法の特徴です。

コード #

エンドポイント

pages/api/hotwire/modal_no_js/index.js

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<string>,
) {
  const users: User[] = await allUsers()

  const resultText = render("modal_no_js/index.ejs",
    {users}
  )

  res.appendHeader("Content-Type", "text/html")
    .status(200)
    .send(resultText)
}

pages/api/hotwire/modal_no_js/modal/[userId].ts

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<string>,
) {
  const id = req.query.userId
  if (!(typeof id === 'string')) throw "Bad request"

  const userDetail = await findUserWithDetails(parseInt(id))

  const resultText = render("modal_no_js/modal.ejs",
    {id: userDetail.id, userDetail}
  )

  res.appendHeader("Content-Type", "text/html")
    .status(200)
    .send(resultText)
}
  • MPAの場合と異なり、User一覧とUser詳細(モーダルの中身)のエンドポイントを分けています

モーダルの「枠」とコンテンツのHTMLページ

templates/modal_no_js/modal.ejs

<turbo-frame id="modal">
   ...[モーダルのコンテンツおよびモーダルの枠]...
</turbo-frame>
  • これはモーダルの中に表示する内容 + モーダルの「枠」です
  • ブラウザからのTurbo Framesリクエストを受けて、サーバからブラウザに送信されます
  • <turbo-frame id="modal">がありますので、ブラウザはこれを受信すると、ブラウザですでに表示されている<turbo-frame id="modal">の箇所を置換します

モーダル表示のトリガー

templates/modal_no_js/index.ejs

<a href="/api/hotwire/modal_no_js/modal?id=<%= user.id %>"
   data-turbo-frame="modal"
   class="text-orange-600 underline inline-block active:scale-105"><%= user.name %></a>
  • aタグをクリックすると、/api/hotwire/modal_no_js/modal?id=1からモーダルの中身(HTML)がダウンロードされます
  • ダウンロードされたHTMLは<turbo-frame id="modal">がありますので(上述)、ブラウザですでに表示されている<turbo-frame id="modal">の箇所を置換します)

モーダルHTML断片の差し込み先

templates/modal_no_js/index.ejs

<turbo-frame id="modal"></turbo-frame>
  • サーバからダウンロードされたHTMLをここに挿入しなさいという指示になります

モーダルを閉じるボタン

templates/modal_no_js/modal.ejs

<a class="p-1 text-sm w-auto hover:cursor-pointer translate-x-2 active:scale-125"
     href="/api/hotwire/modal_no_js">
  • 今回はカスタムJavaScriptを敢えて避けますので、onclick等のイベントハンドラが使えません。ブラウザのインタラクションはaタグやformタグに限定されます
  • そこでaタグを使用して、モーダル表示前のページをリクエストしています。その結果、モーダルが閉じたように見えます

ブラウザが受け取るHTMLレスポンスには空のturbo-frameが含まれています。Turbo Framesはブラウザに表示されている既存のturbo-frameをこの空のturbo-frameに入れ替えます。その結果、ブラウザに表示されているturbo-frameの中身は空になり、枠も無くなるのでモーダルが閉じます。ブラウザが受け取るHTMLはturbo-frameだけではなく、templates/modal_no_js/index.ejsのすべてですが、画面に反映される(差し込まれる)のはturbo-framesの中身(空)だけであることに注意してください。画面全体が書き換えられずにモーダルの中だけが置換されますので、モーダルを閉じるときもスクロール位置が維持されます。

UI/UX #

カスタムJavaScriptなしで作ったデモを用意していますので、ご覧ください。遅延(Delay)を設定できますので、300msの場合と2000msの場合を比較してください。

  • サーバのレスポンスが十分に速い場合(300msの場合)は快適です。
  • しかし遅延を2000msに設定するとUI/UXの弱点が見えてきます。リンクを押してもフィードバックがなく、ユーザはシステムが正しく動作している自信が持てません。またモーダルを閉じる時も遅延が発生します。「閉じるだけなら瞬間的に行くのでは?」というユーザの期待と反して、サーバからレスポンスが返ってくるまでモーダルは閉じません。
    (リンク等をクリックした時にCSS :activeを使っていますが、十分なフィードバックとは言えません)
  • MPAで作った場合と比較して、スクロール位置を維持される点が改善しています

モーダルダイアログ (HotwireカスタムJavaScriptなし バージョン)

まとめ #

  • カスタムJavaScriptなしの場合はコードが非常に簡素で、直感的にもわかりやすいものになります
  • しかし、特にネットワークが遅い場合は、ユーザの操作に対するフィードバックが弱く、良いUI/UXとは言えません
  • スクロール位置を維持してくれる点でMPAで作成したものより改善しています
  • MPAバージョンと比較して、エンドポイントを分けること、data-turbo-frame属性を追加すること、<turbo-frame>タグを追加することが必要になりますが、引き続き非常にシンプルなコードになっています

Turbo Frames: Inline JavaScript #

上記のTurbo Frames: カスタムJavaScriptなしの問題点を解消し、ネットワークが遅い場合でも十分なUI/UXを提供するためには、レスポンスが来る前にフィードバックを提供する必要があります。そのためにはボタンを押した瞬間に起動するカスタムJavaScriptが必要です

上記ではモーダルの「枠」をサーバから送ることでモーダルを開くステップを省略していました。しかしここではカスタムJavaScriptを使って、サーバレスポンスの遅延の有無に関わらず、リクエストを投げた瞬間にモーダルを開きます。これがユーザに対するフィードバックとなり、UI/UXの改善に繋がります。

なおHotwireでカスタムJavaScriptを書く場合は、一般にはStimulusを使います。しかし最初はとっつきにくいので、まずはインラインJavaScriptを使った例から紹介します

コード #

カスタムJavaScriptなしの場合と比較して、以下のコードが追加されます。

  • カスタムJavaScriptなしの場合は、モーダルを表示するときに「枠」のHTMLをサーバから送っていました
  • しかし今回はサーバからのレスポンスを待たずにモーダルを表示するため、「枠」のHTMLをあらかじめ元のページに挿入しておき、CSSで隠して見えなくします。その中にサーバから受け取るHTML断片を差し込む目印のturbo-frameを入れておきます
  • モーダルの表示をトリガーするカスタムJavaScriptを用意します
  • モーダルを閉じるボタンのカスタムJavaScriptも用意します

モーダルの「枠」だけ

templates/modal_w_js/index.ejs

  <div class="modal-dialog relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true">
    <div class="modal-backdrop fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>

    <div class="modal-panel fixed inset-0 z-10 w-screen overflow-y-auto">
      <div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
            ...
            <div class="!min-w-64 !min-h-40 mt-5 sm:mt-6">
              <turbo-frame id="modal" class="turbo-with-loader no-delay">
                <div class="turbo-hide-on-loading ">
                  Loading...
                </div>
              </turbo-frame>
            </div>
            ...
      </div>
    </div>
  </div>

hotwire/styles/input.css

    .modal-dialog .modal-backdrop {
        @apply ease-out duration-300 opacity-0 invisible
    }

    .modal-dialog.modal-open .modal-backdrop {
        @apply ease-out duration-200 opacity-100 visible
    }

    .modal-dialog .modal-panel {
        @apply ease-out duration-300 translate-y-4 sm:translate-y-0 sm:scale-95 opacity-0 invisible
    }
  • モーダルの「枠」が最初からページに埋め込まれています。そして通常は非表示になるようにCSSを用意します。
  • 「枠」の中に<turbo-frame id="modal">が含まれています

モーダルの内容だけを含むHTMLページ:

templates/modal_w_js/modal.ejs

<turbo-frame id="modal">
   ...[モーダルのコンテンツ]...
</turbo-frame>
  • モーダルの中身だけです。枠は上述で最初のページに(非表示で)埋め込んでいますので、ここには含めません。純粋にモーダルの中身だけです
  • サーバにリクエストを投げると、返ってくるのはこのテンプレートの内容です
  • <turbo-frame id="modal">がありますので、ブラウザがこのページを受け取ると、すでに表示されている<turbo-frame id="modal">の箇所をこの内容で置換します

モーダル表示のJavaScript

templates/modal_w_js/index.ejs

<script>
  function openModal() {
    const turboFrame = document.querySelector("turbo-frame#modal")
    turboFrame.innerHTML = '<div>Loading... </div>'
    const modal = document.querySelector(".modal-dialog")
    modal.classList.add("modal-open")
  }

  function closeModal() {
    const modal = document.querySelector(".modal-dialog")
    modal.classList.remove("modal-open")
  }
</script>
  • 最初のページに埋め込まれた「枠」はCSSで非表示になっていました。ここのコードはその「枠」を表示させるものです
  • JavaScriptはscriptタグで囲んでインラインで書いています。通常であればStimulusを使うのですが、ここでは説明するためのわかりやすさを優先し、インラインJavaScriptを使っています。
  • 最初のページに埋め込まれた「枠」に.modal-openのCSSクラスをつけたり外したりするだけです
  • さらにopenModal()では、turbo-frameの"Loading..."と表示させ、ローディング中の表示をさせています。内容が異なっても同じモーダルを使うので、毎回リフレッシュするためです。これをやらないと前回開いた時の内容が一瞬モーダル内に表示されてしまいます

モーダル表示のトリガー

templates/modal_w_js/index.ejs

<a href="/api/hotwire/modal_w_js/modal?id=<%= user.id %>"
   data-turbo-frame="modal"
   onclick="openModal()"
   class="text-orange-600 underline inline-block active:scale-105">
  <%= user.name %>
</a>(Inline JS)<br>
  • aタグをクリックするとモーダルを表示するようにしています。onclick属性でopenModal()を呼び出して、モーダルを表示させています
  • このaタグはdata-turbo-frame="modal"属性もありますので、同時にhrefで指定したエンドポイントにリクエストを飛ばし、返ってきたHTMLをturbo-frame id="modal"に差し込みます。ここは上述の「カスタムJavaScriptなし」のケースと同じです

「カスタムJavaScriptなし」のケースと大きく異なるのは、aタグにこの2つの属性をつけることで、モーダルを表示させるコードとTurbo Frameにデータを読み込むコードを分割したことです。そのため、サーバからのレスポンスが遅延しても、それとは無関係に先にモーダルを表示しています

「モーダルの開閉」と「サーバからのデータの読み込み」を独立させたことにより、UXを改善しているわけです

モーダルを閉じるボタン

templates/modal_w_js/modal.ejs

<button class="p-1 text-sm w-auto hover:cursor-pointer translate-x-2 active:scale-125"
        onclick="closeModal()">
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
       class="mx-auto size-6 text-orange-600">
    ...
  </svg>(Inline JS)
</button>
  • buttonタグでモーダルを閉じます。onclick="closeModal()"がありますので、これが先ほど用意したcloseModal()を呼び出して、モーダルを閉じています。
  • カスタムJavaScriptを使わない例と異なり、モーダルだけが瞬時に閉じられます

UI/UX #

JavaScriptを使った例のデモを用意していますので、お試しください。他の例も同じデモ画面に含めていますので、"Inline Javascript"のUI/UXを確認する場合は、"Inline JS"と書いてあるボタンを押してください。

  • モーダルダイアログは瞬時に表示されます
  • サーバのレスポンスが遅い場合は"Loading..."と表示されますので、リクエストが送信され、サーバのレスポンス待ちであることがわかります。ユーザへのフィードバックが適切にされていますので、UI/UXは良くなっています
  • Hotwireのprefetchが機能しています。リンクの上をホバーすると、フライングして裏でリクエストが飛びます。その分、体感でレスポンスが速いと感じます
  • クローズボタンを押したときにモーダルダイアログは瞬時に閉じます。これはユーザの期待に沿いますので、UI/UXは良くなっています。

モーダルダイアログ(Inline JavaScriptバージョン)

まとめ #

  • カスタムJavaScriptなしの例に比べて多少はコードが増えていますが、10行程度です
  • モーダル「枠」はサーバから送信されるのではなく、元のページに含まれています
  • サーバレスポンスが遅延していても優れたUI/UXを提供できます

Turbo Framesを使うとカスタムJavaScriptなしでもモーダルが作れます。宣伝文句としてはインパクトがあるせいか、このやり方を非常に多く見かけるように思います。しかしHotwireがStimulusとセットになっていることからも分かるように、HotwireはむしろカスタムJavaScriptを書くことを奨励しています。Hotwire流とはJavaScriptを全く書かないことではなく、JavaScriptをたくさん書かないことです。そしてここで確認したように、わずか10行程度のJavaScriptでUI/UXを大きく改善できました。

ぜひ「JavaScriptなし」の宣伝文句に踊らされず、UI/UXを改善するたも少量の簡単なJavaScriptを使うようにしていただければと思います。

hotwire-without-much-javascript

Turbo Frames: CSSローディングアニメーション #

Inline JavaScriptの項では、カスタムJavaScriptを使ったおかげでモーダルが瞬時に表示されるようになりました。そしてサーバからデータが送られるのを待つ間は"Loading..."と表示されるようにしました。

これでも良いのですが、Turboはロード中にbusyのHTML属性を自動的に付けてくれますので、CSSだけでローディングアニメーションを表示させることもできます。ここではこの方法を紹介します。

コード #

デモで使用しているコードはもう少し複雑ですが、ポイントを下に記しました。

hotwire/styles/input.css

.turbo-with-loader .turbo-hide-on-loading {
    /* GIFイメージは`position: absolute`で配置するので、
        ここを`position: relative` にする*/
    position: relative;
}

.turbo-with-loader .turbo-hide-on-loading::before {
    visibility: hidden !important;
    opacity: 0 !important;
}

.turbo-with-loader[busy] .turbo-hide-on-loading {
    visibility: hidden !important;
}

.turbo-with-loader[busy] .turbo-hide-on-loading::before {
    content: '';
    visibility: visible !important;
    opacity: 1 !important;
    position: absolute !important;
    display: block;
    background-image: url('../../images/rocket.gif');
    width: 64px;
    height: 64px;
    top: 40px;
    left: 50%;
    transform: translateX(-50%);
}
  • turbo-frameにはturbo-with-loaderCSSクラスを付けています。
  • アニメーション表示のときに隠したい部分をturbo-hide-on-loadingで囲っています。ここの::before擬似要素にローディングのGIFアニメーションを表示します
  • アニメーションの表示・非表示はturbo-with-loaderCSSクラスがついているturbo-framebusy属性で切り替えます(.turbo-with-loader[busy]セレクタ)。busyなら隠したい部分(turbo-hide-on-loadingを持つ)が非表示になり、かつ::beforeのところのGIFアニメーションが表示されます
  • 結論として、turbo-with-loaderturbo-hide-on-loadingを適切にHTMLに付けてあげるだけでローディングアニメーションが動きます

UI/UX #

デモ画面の"CSS loader"と付いているボタンを押してください。このボタンを使うと、turbo-hide-on-loadingがついたHTML要素が使われ、適切にアニメーションが動きます。特にサーバレスポンスを遅延させてお試しください。

  • モーダルが表示されると中にアニメーションが表示され、システムが正しく動作している安心感をユーザに与えます。良いUI/UXです

モーダルダイアログ(CSSローディングアニメーションバージョン)

まとめ

Stimulusでイベント処理を改善 #

上記のように、Turbo FramesとカスタムJavaScriptおよびCSSを使うと、かなりUI/UXの良いモーダルダイアログが作成できます。サーバからリクエストを受け取ってデータを表示しているにも関わらず、必要なコードは少なく、処理の流れも直感的でわかりやすくなっています。

しかしHTMLのonclick属性を使用しているところは問題です。onclick属性等のインラインのイベントハンドラーはとても使いやすいのですが、一般には使用するべきではないとされています。そこでここではHotwireに含まれるStimulusを使います。

Stimulusは下記の特徴があります。

  • AJAX/fetchをするページでのイベントハンドラー処理を完全自動化。addEventListener()より使い勝手が良いです
  • HTMLとJavaScriptの関連が分かりやすくなります
  • JavaScriptコードの再利用性が高まるような設計の工夫がされています
  • キーボードイベントなどの処理も簡略化されています

StimulusはDOM MutationObserver APIを使用してDOMの変更を監視し、そのタイミングでイベントハンドラーを接続します。従来はloadイベントを受け取りelement.addEventListener()を使うことが多かっのですが、SPAではloadイベントが発火しないために問題があります。動的に読み込まれる要素が多いウェブページであっても、Stimulusは確実にかつ簡単にイベントハンドラーを接続してくれます。なおReactの場合はuseEffectを使ってコンポーネントがロードを検知できるので、SPAのload問題を回避できています

Stimulusを使うと、コードを小さいコントローラに分割することになりますので、コードの整理整頓につながります。またdata-controller="..."属性をHTML側につけますので、どのHTML要素がどのStimulus Controllerを呼び出すのが明快です。jQueryや素JavaScriptのaddEventListener()を多用した場合と比べると大きな改善です

今回は各ポイントに軽く触れながら、Stimulusで書いたコードを紹介します。CSSローディングアニメーションをつけたデモをベースに話します。

コード #

モーダル表示のトリガー

templates/modal_w_js/index.ejs

<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"
     data-controller="labeler"
     data-labeler-selector-value=".modal-dialog"
     data-labeler-label-class="modal-open"
>
  ...
  <a href="/api/hotwire/modal_w_js/modal?id=<%= user.id %>"
     data-turbo-frame="modal"
     data-action="click->labeler#add"
     class="text-orange-600 underline inline-block active:scale-105">
    <%= user.name %>
  </a>(Stimulus)
  ...
</div>
  • モーダル表示のトリガーです。aタグをクリックしたときにモーダルが表示されます
  • モーダル表示の処理をするJavaScriptはStimulus Controller(public/hotwire/javascript/labeler_controller.js 下で解説)のに記述されています。data-controller, data-labeler-selector-value, data-labeler-label-class, data-actionのHTML属性の組み合わせにより、下記のStimulus Controllerのコードが呼び出されています
    • data-controller="labeler"は、このdivタグに囲まれている部分をjavascript/labeler_controller.jsに繋げます
    • data-labeler-selector-value=".modal-dialog"data-labeler-label-class="modal-open"javascript/labeler_controller.jsの動きをカスタマイズするための引数です。どのモーダル「枠」を制御するか、および開閉指示に使用するCSSクラスを指定しています。一見すると冗長ですが、これによって他の場面でも同じStimulus Controllerが使用でき、再利用性が高まります
    • aタグをクリックすると、data-action="click->labeler#add"属性の指示により、Stimulus Controllerのadd()メソッドが呼び出されます

Stimulus Controller – モーダル表示のJavaScript

public/hotwire/javascript/labeler_controller.js

import {Controller} from "/hotwire/javascript/stimulus.js"

export default class extends Controller {
  static values = {"selector": String}
  static classes = ["label"]

  add() {
    const labelable = document.querySelector(this.selectorValue)
    labelable.classList.add(this.labelClass)
  }

  remove() {
    const labelable = document.querySelector(this.selectorValue)
    labelable.classList.remove(this.labelClass)
  }
}
  • add()メソッドはthis.selectorValueで指定されたDOM要素に、this.labelClassで指定されたCSSクラスをつけます。remove()は逆にCSSクラスを外します
  • 上のthis.selectorValueおよびthis.labelClassは、それぞれ上述の「モーダル表示のトリガー」で指定したdata-labeler-selector-value=".modal-dialog"およびdata-labeler-label-class="modal-open"の値が入ってきています
  • 上述の「モーダル表示のトリガー」のaタグのdata-action="click->labeler#add"により、クリック時にadd()が呼び出されます

モーダルを閉じるボタン

templates/modal_w_js/index.ejs

<button class="p-1 text-sm w-auto hover:cursor-pointer translate-x-2 active:scale-125"
        data-action="click->labeler#remove keydown.esc@window->labeler#remove">
  • モーダルを閉じるボタンです
  • buttonタグのdata-action="click->labeler#remove"により、クリックされると上述のStimulus Controllerのremove()メソッドが実行され、モーダルを閉じます
  • さらにbuttonタグのdata-action="keydown.esc@window->labeler#remove"により、windowkeydownイベントハンドラーが接続されていますので、Escキーが押されたときにStimulus Controllerのremove()が呼び出され、モーダルを閉じてくれます

このようにStimulusを使うとキーボードショートカットも非常に簡単に実装できます

まとめ #

Stimulusを使ったデモで"Simulus"ボタンをクリックすることでUI/UXを確認できます。CSS loaderのInline JavaScriptを使った場合と動作は同じですが、"ESC"ボタンでモーダルを閉じる機能が追加されています。

  • Stimulusを使うとJavaScriptのイベントハンドリングの付け外しが簡単になります
  • コードはStimulus controllerにまとめられ、整理されます
  • 汎用的なcontrollerを作れば、必要な引数はHTML属性だけで渡せます
  • キーボードイベントなどにも簡単に対応できます

React: useEffectでモーダルを出す方法 #

比較のためにReactのuseEffectでモーダルを出す一般的な方法をおさらいします。こちらにuseEffectのデモを用意しています。コードはGitHubにあります。

コード #

モーダル表示のトリガー

pages/modal/index.tsx

export default function ModalIndex({users}: {users: User[]}) {
  const [selectedUserId, setSelectedUserId] = useState<number | null>(null);

  return (
    <Layout>
        <>
          ...
          <button className="underline text-orange-600 inline-block active:scale-105"
                  onClick={() => setSelectedUserId(i + 1)}>{user.name}</button>
          ...
        </>
      { selectedUserId && <Modal id={selectedUserId} closeModal={() => setSelectedUserId(null)} />}
    </Layout>
  )
}
  • Reactはすべてのレンダリングはステートから出発するという原則があります。そのためselectedUserIduseState()で作っています。Hotwireの場合は明示的にステートを用意せず「画面に表示されているものがステートだ!」という立場を取っているのと対照的です。
  • buttonタグのonClick={() => setSelectedUserId(i + 1)}属性により、クリックするとselectedUserIdステートにIntegerがセットされます
  • { selectedUserId && <Modal id={selectedUserId} ... />}の箇所で、条件付きレンダーを使っています。selectedUserIdがセットされているとModalコンポーネントをレンダリングします
  • buttonタグのcloseModal={() => setSelectedUserId(null)}はモーダルを閉じる時の関数です。selectedUserIdステートをnullにすればモーダルは閉じられます

モーダル表示内容の取得およびキーボードイベントの接続

components/Modal.tsx

export default function Modal({closeModal, id}: {
  closeModal: () => void,
  id: number,
}) {
  const [userDetail, setuserDetail] = useState<User & UserDetail | null>(null);

  useEffect(() => {
    setuserDetail(null)
    fetch(`/api/user/${id}`).then(res => res.json())
      .then(data => {
        setuserDetail(data)
      })
  }, [id])

  useEffect(() => {
    const close = (e: KeyboardEvent) => {
      if(e.key === "Escape"){
        closeModal()
      }
    }
    window.addEventListener('keydown', close)
    return () => window.removeEventListener('keydown', close)
  }, [])

  return <>
<div>
  <div className="p-1 text-sm w-auto hover:cursor-pointer translate-x-2 active:scale-125"
       onClick={closeModal}>
    ...
  </div>
</div>
<div className="mt-5 sm:mt-6">
  {
    !userDetail
      ? <div>
        <Image src={rocketImage} alt="loader" className="m-auto mt-10 w-16 h-16"/>
      </div>
      : <div>
        ...[モーダルの内容]...
      </div>
  }
</div>
  </>
}
  • ModalコンポーネントにはuseEffectが2つあります
    • 1つはidが変更されたときにサーバにリクエストを投げて、userDetail取得しています。これはモーダルの内容を表示するために使用されます
    • もう1つのuseEffectはEscapeキーでモーダルが閉じられるように、keydownイベントハンドラーをwindowに接続するものです
  • userDetailがまだロードされていない時は、条件付きレンダーでローディング画面を表示します

UI/UX #

  • Hotwire Turbo FramesでカスタムJavaScriptを使った場合とほぼ同じUI/UXです。瞬間的にモーダルが表示され、十分なフィードバックがあります
  • ただしprefetchは機能しませんので、レスポンス時間は速くなりません

モーダルダイアログ(React useEffectバージョン)

まとめ #

  • useEffectのデモでもご覧いただけるように、すぐにフィードバックがある良好なUI/UXが実現できています。コードも少なく、流れが直感的にわかります
  • Escapeでモーダルを閉じるコードではイベントハンドラの付け・外しを書かなければならないので、Stimulusよりは若干手間ですが、小さな差です
  • UI/UXはHotwireと同程度で問題ありませんが、prefetchが効かないので実質的なレスポンス時間はMPAの場合と同じです
  • Reactではイベントハンドラから直接DOMを操作しません。イベントハンドラで先にステートを変更し、ステートの変更をDOMに伝搬させるという原則があります。この考え方をStimulusに適応することもありますが(例えばChange Callbackを使用します)、Stimulusは通常はイベントハンドラから直接DOMを操作します。この考え方に慣れる必要があるかもしれません

React: Client Componentsでモーダルを出す方法 #

ReactではRSC(React Server Components)が話題になっていますので、Client Componentを使ってモーダルを作りました。こちらにClient Componentのデモを用意しています。コードはGitHubにあります。

結論から言うと、useEffectでモーダルを出す場合と書き方もUI/UXも差がありません。確認した限りではClient Componentの書き方はPages routerでuseEffectを使った場合と同じになります。

useEffectの場合とほぼ同じため、ここでは解説を省きました)

React: Server Componentsでモーダルを出す簡易法 #

モーダルはインタラクティブな要素ですので、上記のようにClient Componentで作るのが王道に思えます。公式ドキュメントでもClient Componentのメリットを下記のように紹介しています。

Interactivity: Client Components can use state, effects, and event listeners, meaning they can provide immediate feedback to the user and update the UI.

一方でParallel RoutesとIntercepting Routesを使って、Server Componentでモーダルを実現する例が公式ドキュメントで紹介されています。

そこで、ここでは実際にServer Componentでモーダルを作って見ながら、そのUI/UXやコードの書き味を確認したいと思います。ただしIntercepting routesとParallel routesは複雑なので、まず最初に簡易法を紹介し、その次にParallel routesによるを紹介します。なお簡易法はここのブログ記事で知りました

コード #

コードは簡単なものです。考え方はMPAを使った方法とほぼ同じです。

モーダルの表示

app/modal_app/page.tsx

async function getUsers(): Promise<User[]> {
  console.log("Fetch start for Users")
  const res = await fetch(process.env.URL + "/api/users")
  const users = await res.json()
  return users
}

export default async function ModalAppPage({searchParams}: { searchParams: { userId: string | undefined } }) {
  const userId = searchParams.userId
  const users = await getUsers()

  return (
    <>
      ...[ページのコンテンツはここ]...
      <Link href={`/modal_app?userId=${user.id}`}
            className="underline text-orange-600 inline-block active:scale-105"
            scroll={false}>
        {user.name}
      </Link>
      ...[ページのコンテンツはここ]...
      {userId && <Modal userId={userId}/>}
    </>
  )
}
  • /modal_appでこのページにアクセスした場合はモーダルが表示されない
  • /modal_app?userId=1でこのページにアクセスした場合は、userId=1のUserの情報がモーダルに表示される

これを実装するために以下のロジックになっています。

  • Linkタグのところをクリックすると、/modal_app?userId=1などに遷移します
  • userId = searchParams.userIdのところで、URLの?userId=1の箇所を読み取り、userId変数に格納します
  • 最後の{userId && <Modal userId={userId}/>}で条件付きレンダーをして、userIdがセットされている場合はModalコンポーネントを表示します

なおLinkタグにはscroll={false}がありますので、リンク先に遷移する場合でもスクロール位置が変わらず、元の画面のステートが保たれます。

このように、「モーダルだけ」を表示するように見せかけているが、実際にはページ全体が再表示されています。でもネットワークが速い場合はこれでもUI/UXとしては十分で、全く気になりません。

モーダルのクローズ

app/modal_app/components/Modal.tsx

export default async function Modal({userId}: { userId: string }) {
  const userDetail = await getUserDetails(userId);

  return (
    ...[モーダルの内容]...
      <Link className="p-1 text-sm w-auto hover:cursor-pointer translate-x-2 active:scale-125"
            href={`/modal_app`}
            scroll={false}
            prefetch={true} >
        ...[ボタンのイメージ]...
      </Link>
    ...[モーダルの内容]...
  )
}
  • せっかくモーダルを表示するところまではServer Componentだけで来ましたので、モーダルを閉じるところもServer Componentで実装しています
  • Server Componentでは、インタラクティブ要素はaタグとformタグしか使えません。ここではaタグを/modal_appに向けました。/modal_app1. で示した通り、モーダルを表示しませんので、実際には画面全体が再表示されているのですが、見かけ上はモーダルだけが消えたように映ります

UI/UX #

Server Componentsでモーダルを出す簡易法のデモはこちらで、コードはGitHubにあります。

  • サーバのレスポンスタイムが300ms程度であれば、快適なUI/UXになります。これはMPAの例の例と同じです
  • ただしレスポンスタイムを2000msに設定するともたつきを感じます。モーダルを表示する時も、そしてモーダルを閉じる時もフィードバックがなく、ユーザは不安を感じてしまいます。これもMPAの例と同じです
  • MPAの例と異なるのは、スクロール位置が維持される点です。この点ではカスタムJavaScriptなしの例と同じになります
  • Hotwireと異なり、ダイナミックなコンテンツではprefetchは効きませんので、実質的なレスポンスは速くなりません

モーダルダイアログ(Server Components簡易法)

まとめ #

  • コードは非常にシンプルです。MPAの例と同じです
  • サーバのレスポンスタイムが早ければ、この簡易法でも十分快適なUIを提供してくれます
  • しかしサーバのレスポンスが遅い可能性がある場合は、Server Componentにこだわらず、Client Componentを使ってカスタムのイベントハンドラを書いた方が良いでしょう。これはTurbo FramesでもカスタムJavaScriptを書くべきだという話と全く同じです
  • スクロール位置が維持できる点ではMPAの例よりも優れています
  • prefetchが効かないので実質的なレスポンスは速くなりません

React: Server Components /w Parallel Routes #

Server Componentを使ったもう1つの方法を紹介します。Parallel Routesを使用します。

私が理解している限りでは、Reactは元々コンポーネント全体をレンダリングすることを想定していました。ただのテキストであるHTMLと異なり、Turbo FramesやTurbo Streamsのように単純に分割したり貼り付けたりすることが得意ではありません。そこでNext.jsがApp Router導入したのがLayoutParallel routesです。

そしてParallel Routesを使ったモーダルの作り方が公式ドキュメントで紹介されています。ここではそのやり方を紹介します。

コード #

コードはGitHubに掲載しています。またデモはこちらでご確認いただけます。

パーツが多いので、概要だけ示します

  1. layout.tsx: 部分置換をしても変わらない部分はlayout.tsxに配置します。今回のケースではUserのリスト(UserListコンポーネントとして分割)はlayout.tsxに配置します。またlayout.tsxにはモーダルを表示するための「窓」を{modal}として用意しています。これは@modalフォルダの中を参照するようにNext.jsで自動的に関連づけられています
  2. @modal/page.tsx: ブラウザが/modal_app_parallelにアクセスした時、上記のlayout.tsx{modal}に差し込まれるのが@modal/page.tsxです。モーダルは表示しませんので、何も表示しません。そこでnullを返します
  3. @modal/users/[userId]/page.tsx: ブラウザが/modal_app_parallel/users/1にアクセスした時、上記のlayout.tsx{modal}に差し込まれる内容です。[userId]の内容を読みとり、モーダルの枠およびUserのデータを返します
  4. @modal/loading.tsx: ブラウザが/modal_app_layout/users/1にリクエストを投げ、レスポンスを待っている間にlayout.tsx{map}に差し込まれる内容です。モーダルの枠およびローディングアニメーションを含んでいます。

コードの詳細の解説は今回は省略させていたきますが、難しいのは各ファイル・フォルダの役割と配置です。他の実装方法と比較するとかなり複雑で、かなり慣れが必要です。

UI/UX #

  • サーバのレスポンスが速い場合は、快適なUI/UXが実現できています
  • サーバのレスポンスが遅くても、モーダルを表示させる際はすぐにローディングアニメーションが表示され、フィードバックが得られます。十分なUI/UXが提供できています
  • モーダルを閉じる場合はモーダルを表示したまま、すぐに中でローディングアニメーションが表示されます。UI/UX的にはそれほど悪くはありませんが、理想的にはモーダルもすぐに閉じてほしいところです(現時点でこれを改善するためにはClient Componentが必要になります)
  • Next.jsはダイナミックなページ内容ではprefetchが効きませんので、実質的なレスポンスは速くなりません

モーダルダイアログ(Server Components Parallel Routes法)

まとめ #

  • 必要なファイル・フォルダの役割を理解するのが大変です。少なくとも私は複雑で直感的ではないと感じました。他の方法と比べると複雑だと言えます
  • 簡易法と比べて、モーダルを表示する時のUI/UXが改善されています
  • サーバのレスポンスが遅いケースでモーダルを閉じる際のUI/UXは最適ではありません

コードが複雑なのがこのアプローチの欠点だと思います。

各技術のまとめ #

今回は大きく分けて7通り、細かく分けると9通りのモーダルダイアログの出し方を検討しました。これでも全然網羅的ではなく、他の方法もきっとあるはずです。その中で以下のことが言えるのではないかと思います。

  • Turbo Framesを使用する場合、JavaScriptなしで非常に簡単に実装することができます。しかし、もう少しだけ頑張ってカスタムJavaScriptおよびCSSを書けば、使い回し可能でUI/UXが優れたモーダルダイアログが作れます。なるべくならば少し手間をかけてここまでやっていただければと思います
  • Turbo FramesでStimulusを使用すると、少ないコードで優れたUI/UXが実現でき、メンテナンス性も良好です
  • Reactを使う場合は、useEffectなどを使い、ブラウザでデータをフェッチし、かつJavaScriptでDOMを操作するのが良さそうです。これはPages RouterでもApp Router (Client Component)でも変わりません。コードも簡単でわかりやすく、UI/UXも優れています
  • React Server Componentsを使ったモーダルの作り方がNext.jsの公式サイトに紹介されています。しかしコードのわかりやすさとUI/UXを総合的に加味すると、私としては現時点でお勧めできません

最後に、現時点のReact Server ComponentsはUI/UX的にも、コードを書くときの考え方にしても、MPAと酷似していると感じます。RSC payloadを自在に組み立てられるようになると、Hotwire的な使い方ができるようになるのではないかと思います。実際、Server ActionからJSX (というかRSC payload)が返せるという話もあるので、近い将来にこの方向に行く可能性も感じます。