モーダル
十分な機能を持ったモーダルをHotwireで自作する方法を紹介します。下記のビデオのものになります。 なおモーダルの中に表示する内容はサーバから非同期で取得するものとします。
モーダルは簡単に作れると考える人が多くいます。しかし実際にはモーダルの表示・非表示だけでも、かなり多くの機能が必要になります。これがなければ十分な機能を持ったモーダルとはいえず、そもそもモーダルではなく、普通のMPAの機能で十分な可能性がありますので、UIの選択としてモーダルが間違っている可能性があり、再考が必要です。
本サイトではHotwireを使って本格的なUI/UXを作成するのが目的です。偶有的な複雑性(accidental complexity)はHotwireを使うことで回避できます。しかし本質的な複雑性(essential complexity)とは真剣に向き合うしかありません。そのため、解説はかなりの分量になっていますが、ご容赦ください。
data-*-value属性を外部から変更してモーダルの開閉状態を制御でき、便利ですbody要素の直下ぐらいに配置するの一般的です(一方で黒い幕を使わないポップアップダイアログであれば、モーダルのHTMLはボタンの近くに用意することが多いです)。今回もそのようにします
# ... <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('/Rolling@1x-1.4s-200px-200px.svg')]"> </div> </div> </div> </div> </div>
id="modal-dialog"をつけます
ModalDialogTriggerControllerとModalDialogControllerの複数controller間通信をします。そしてStimulusでcontroller間通信をする場合、querySelector()で使うようなCSSセレクタでHTML要素を指定します。idをつけているのはそのためですdata-controller="modal-dialog"属性で、モーダルのHTMLをModalDialogControllerStimulus 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は自動的に<code>aria-busy</code>を<code><turbo-frame></code>タグに追加してくれます
aria-busyをCSS擬似セレクタで読みとり、ローディング中は<turbo-frame>そのものを非表示しています。前回表示したモーダルの内容が残っていて、これを表示させたく無いためです <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は何もしません。上述の黒い幕をクリックした時の動作で使用しました。dialogを使ったり、モーダル用のライブラリを使ったり、さらにUI/UXをそれに合わせていかない限り、なかなか避けられません