モーダル
十分な機能を持ったモーダルをHotwireで自作する方法を紹介します。下記のビデオのものになります。 なおモーダルの中に表示する内容はサーバから非同期で取得するものとします。
モーダルは簡単に作れると考える人が多くいます。しかし実際にはモーダルの表示・非表示だけでも、かなり多くの機能が必要になります。これがなければ十分な機能を持ったモーダルとはいえず、そもそもモーダルではなく、普通のMPAの機能で十分な可能性がありますので、UIの選択としてモーダルが間違っている可能性があり、再考が必要です。
本サイトではHotwireを使って本格的なUI/UXを作成するのが目的です。偶有的な複雑性(accidental complexity)はHotwireを使うことで回避できます。しかし本質的な複雑性(essential complexity)とは真剣に向き合うしかありません。そのため、解説はかなりの分量になっていますが、ご容赦ください。
inert
属性を使いますdata-*-value
属性を外部から変更してモーダルの開閉状態を制御でき、便利ですbody
要素の直下ぐらいに配置するの一般的です(一方で黒い幕を使わないポップアップダイアログであれば、モーダルのHTMLはボタンの近くに用意することが多いです)。今回もそのようにします
inert
を使って裏の画像を制御できないようにするなどの工夫は実施します# ... <body class="relative"> <div id="page"> <%= render "nav", show_data_reset: true %> <div class="container container-lg mx-auto px-4 pt-8"> <div><%= content_for(:breadcrumbs) %></div> <%= render 'variants_selector' %> <%= yield %> </div> </div> <%= render "modal_dialog" %> <%= render 'global_notification' %> </body> </html>
<body>
タグに近いところですmodal_dialog
partialとして)<div id="page">
内に配置します。これはモーダルが表示された時に背面になり、黒い幕で隠される部分です。<div class="group relative z-10 collapse opacity-0 transition-all duration-200 data-[modal-dialog-shown-value=true]:visible data-[modal-dialog-shown-value=true]:duration-300 data-[modal-dialog-shown-value=true]:opacity-100" id="modal-dialog" data-controller="modal-dialog" data-modal-dialog-page-value="#page" data-action="keydown.esc@window->modal-dialog#hide:stop:prevent" aria-labelledby="modal-title" role="dialog" aria-modal="true"> <!-- Background backdrop, show/hide based on modal state. Entering: "ease-out duration-300" From: "opacity-0" To: "opacity-100" Leaving: "ease-in duration-200" From: "opacity-100" To: "opacity-0" --> <div class="fixed inset-0 bg-gray-500/75 transition-all opacity-0 ease-in duration-200 group-data-[modal-dialog-shown-value=true]:opacity-100 group-data-[modal-dialog-shown-value=true]:ease-out group-data-[modal-dialog-shown-value=true]:duration-300" aria-hidden="true"></div> <div class="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" data-action="click->modal-dialog#hide"> <!-- Modal panel, show/hide based on modal state. Entering: "ease-out duration-300" From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" To: "opacity-100 translate-y-0 sm:scale-100" Leaving: "ease-in duration-200" From: "opacity-100 translate-y-0 sm:scale-100" To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" --> <div class="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl sm:my-8 sm:w-full sm:max-w-sm sm:p-6 transition-all opacity-0 translate-y-4 sm:translate-y-80 sm:scale-95 ease-in duration-200 group-data-[modal-dialog-shown-value=true]:opacity-100 group-data-[modal-dialog-shown-value=true]:translate-y-0 group-data-[modal-dialog-shown-value=true]:scale-100 group-data-[modal-dialog-shown-value=true]:ease-out group-data-[modal-dialog-shown-value=true]:duration-200" data-action="click->modal-dialog#void:stop" > <turbo-frame id="modal-dialog__frame" data-modal-dialog-target="clearable" class="peer aria-busy:hidden"> </turbo-frame> <div class="hidden peer-aria-busy:absolute peer-aria-busy:block peer-aria-busy:inset-0 peer-aria-busy:bg-contain peer-aria-busy:bg-no-repeat peer-aria-busy:bg-center peer-aria-busy:bg-[url('/[email protected]')]"> </div> </div> </div> </div> </div>
id="modal-dialog"
をつけます
ModalDialogTriggerController
とModalDialogController
の複数controller間通信をします。そしてStimulusでcontroller間通信をする場合、querySelector()
で使うようなCSSセレクタでHTML要素を指定します。id
をつけているのはそのためですdata-controller="modal-dialog"
属性で、モーダルのHTMLをModalDialogController
Stimulus controllerに繋げますmodal-dialog-shown-value
属性を持たせます。これがStimulus controllerのステートになります
group-data-[modal-dialog-shown-value=true]
を使って、この属性に応じたCSSを出し分けています。modal-dialog-shown-value="true"
ならモーダルが表示され、"false"
なら非表示になります<turbo-frame id="modal-dialog__frame">
タグを持たせています。サーバから読み込まれた内容はここに挿入されます
<turbo-frame>
にロードするデータをサーバにリクエストしている間、Turboは自動的にaria-busy
を<turbo-frame>
タグに追加してくれます
aria-busy
をCSS擬似セレクタで読みとり、ローディング中は<turbo-frame>
そのものを非表示しています。前回表示したモーダルの内容が残っていて、これを表示させたく無いためです peer
を使って、<turbo-frame>
の下にある<div class="...peer-aria-busy:...">
属性の表示・非表示をコントロールしています。これはローディングアニメーションを表示する箇所ですdata-action="click->modal-dialog#hide"
の属性を持つHTML要素が黒い幕の<div>
です。これをクリックすると後述するModalDialogController
のhide()
メソッドが呼ばれて、モーダルが非表示になりますdata-action="click->modal-dialog#void:stop
の属性を持つHTML用紙がモーダルのコンテンツの枠ですModalDialogController
のvoid()
メソッドが呼ばれますが、void()
メソッド自身は何もしませんclick->modal-dialog#void:stop
のstop
の部分です。これはevent.stopPropagation()
を呼んでくれますevent.stopPropagation()
が呼ばれますので、クリックイベントはこのレイヤーでブロックされ、後ろの黒い背景に伝播しません。そのため、ModalDialogController
のhide()
メソッドが呼ばれることはなく、このクリックは無視されます<% highlight = local_assigns.fetch(:highlight, false) %> <tr class="group p-2" id="<%= dom_id(todo) %>"> <td class="<%= 'highlight-on-appear' if highlight %> p-2 border-gray-400 border-t group-[:first-child]:border-none"> <div class="flex"> <div class="flex grow items-center"> <%= render 'like_button', todo: %> <%= todo.title %> <%= link_to edit_todo_path(todo), class: "ml-2", data: {controller: "modal-dialog-trigger", modal_dialog_trigger_modal_dialog_outlet: "#modal-dialog", action: "click->modal-dialog-trigger#show", turbo_frame: "modal-dialog__frame" } do %> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/> </svg> <% end %> </div> <div class="text-xs shrink-0 pr-2"><%= l todo.created_at, format: :short %></div> <div class="shrink-0"><%= render 'delete_button', todo: %></div> </div> </td> </tr>
link_to
は<a>
タグを作成します。これをクリックするとモーダルが表示されるようにしています
data-controller-modal-dialog-trigger
で、この<a>
タグをModalDialogTriggerController
に接続しています
ModalDialogTriggerController
はModalDialogController
にメッセージを中継するだけのControllerです。クリックされたことをリレーしますdata-modal-dialog-trigger-modal-dialog-outlet="#modal-dialog"
のところは、接続先のStimulus Controllerを選択するCSSセレクタです。今回はid="modal-dialog"
のHTML要素に接続しているStimulus Controller (ModalDialogController
)が接続されますdata-action="click->modal-dialog#show"
を設定し、クリックするとModalDialogTriggerController
のshow()
メソッドが実行されるようにしています。中継されたメッセージは上記Outletで指定したModalDialogController
のshow()
メソッドに到達し、モーダルダイアログが表示されますturbo_frame: "modal-dialog__frame"
の属性が指定されていますので、<a>
タグのリンク先からのレスポンスは<turbo-frame>
の中に表示されますimport { Controller } from "@hotwired/stimulus" // Connects to data-controller="modal-dialog-trigger" export default class extends Controller { static outlets = [ "modal-dialog" ] connect() { } show() { this.modalDialogOutlets.forEach(modal => modal.show()) } hide() { this.modalDialogOutlets.forEach(modal => modal.hide()) } }
ModalDialogController
にリレーして、モーダルを表示してもらうためのStimulus Controllerですroot
近くに配置されていて、(Todoリストの中の)モーダルを開くボタンとはDOM的に距離が遠いためです。一つのStimulus Controllerで制御しようと思うと、Todoリストを覆い、かつモーダルダイアログも覆わなければなりませんが、これだと制御範囲が大きくなりすぎて、コードがわかりにくくなることを懸念しています。そのための分割ですModalDialogController
はstatic outlets = [ "modal-dialog" ]
で宣言していますshow()
メソッドでは、ModalDialogController
のshow()
メソッドを呼び出しているだけです。hide()
も同様です。import {Controller} from "@hotwired/stimulus" // Connects to data-controller="modal-dialog" export default class extends Controller { static values = { shown: {type: Boolean, default: false}, page: String } connect() { this.pageElement = document.querySelector(this.pageValue) } show(event) { this.shownValue = true } hide(event) { this.shownValue = false } hideOnSuccess(event) { if (!event.detail.success) return this.hide(event) } // Used to prevent browser default behavior on specific elements. void(event) { } shownValueChanged() { if (this.shownValue) { this.#makePageUnresponsive() } else { this.#restorePageResponsiveness() } } #makePageUnresponsive() { this.pageElement.inert = true } #restorePageResponsiveness() { setTimeout(() => this.pageElement.inert = false, 100) } }
static values =
でValues ステートを宣言しています
data-modal-dialog-shown-value
は、ダイアログボックスの表示・非表示を指定するステートです。CSSはこれを読み取り、モーダルダイアログの表示・非表示を自動的に切り替えてくれますdata-modal-dialog-page-value
は、モーダルによって隠蔽される背景画面を指定するCSSセレクタです。この要素にinert
属性を指定することで、モーダルが開いた時に操作を受け付けなくします。なお、この要素はModalDialogController
の制御よりも外側にあるため、target
で指定できません。そのためにCSSセレクタで指定しています
div
を)被せるとマウスクリックはブロックできますが、キーボードショートカット(エンター、タブなど)やスクロールは背景画面に届いてしまいます。完全にブロックするのがinert
属性です。なおモーダルを隠すときはすぐにinert
を解除せずに、少しだけ時間を空けています。そうしないとエンターキーで<input>
タグが選択せれてしまうようなので、これを防ぐためです。show()
, hide()
はStimulus Actionで、ともにdata-modal-dialog-shown-value
ステートをセットしているだけです。この値はHTML要素の属性となりますので、CSS擬似セレクタが監視しています。そしてモーダルダイアログの表示・非表示が制御されますshownValueChanged()
は、data-modal-dialog-shown-value
ステートが変更された時に自動的に呼び出されるコールバックです。CSSだけで制御できないものについてはここで処理します。
this.pageElement
)にinert
属性をつけたり外したりして、背景画面が操作できないようにしますvoid()
のStimulus Actionは何もしません。上述の黒い幕をクリックした時の動作で使用しました。createPortal()
を使って、制御したいパーツが分散する問題に対応します。似たような機能はStimulusにはありませんが、controller間通信で解決できますdialog
を使ったり、モーダル用のライブラリを使ったり、さらにUI/UXをそれに合わせていかない限り、なかなか避けられません