Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: Hotwire流のモーダルをStimulusやTurbo FramesやTurbo Streamsで作る(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

参考: アクセシビリティ - Wikipedia

日本語タイトルは内容に即したものにしました。

Rails: Hotwire流のモーダルをStimulusやTurbo FramesやTurbo Streamsで作る(翻訳)

モーダルは Webで広く使われていますが、アクセシビリティに配慮して構築されていることはめったにありません。モーダルを表示すると背景が不活性(inert)になっても、スクリーンリーダーとキーボードだけを使っているユーザーには引き続き背景が表示されたりします。

しかし、まずはモーダルのアクセシビリティを高めるために何をする必要があるかを見ていきましょう。

🔗 モーダルのアクセシビリティを改善するにはどうすればよいか

モーダルのアクセシビリティを高めるには、以下が必要です。

  • スクリーンリーダーの背景を非表示にする
  • 「フォーカストラッピング(focus trapping)」を実装して、ユーザーがキーボードでモーダル外の要素にフォーカスできないようにする
  • モーダル内で最初のフォーカス可能な要素(存在する場合)にフォーカスを移動しておく
  • Escキーを押したらモーダルが閉じるようにする

最初の2つは背景要素にinert="true"を設定すれば実現できますが、この属性はモーダルの上位要素には設定できません。

最後の2つは、カスタムJavaScriptも併用すれば実現できますが、ご想像のとおり、これはかなり面倒です。

HTMLの<dialog>要素は上記のリストのほとんどを無料で提供し、世界中のユーザーの94.25%にサポートされているため、ほとんどの場合に最適です。

<dialog>要素をもう少し詳しく見てみましょう

🔗 <dialog>要素

MDNでは以下のように説明されています。

HTMLの<dialog>要素は、モーダルダイアログボックスと非モーダルダイアログボックスのどちらを作成する時にも使用します。 モーダルダイアログボックスは、ページの他の部分との操作を中断し、非モーダルダイアログボックスは、ページの他の部分との操作を許可します。

つまり、<dialog>要素は必ずしもモーダルにしか使えないわけではなく、ドキュメントフローでも表示できるということです。

🔗  <dialog>を表示する

ダイアログをモーダルとして表示するには、JavaScriptを使う必要があります。

<dialog>
  Lorem ipsum ....
</dialog>
const modal = document.querySelector("dialog")
modal.showModal()

この方法でダイアログを表示すると、背景は不活性になり、フォーカスはモーダル内に固定され、Escキーでモーダル閉じることが可能です。

<dialog>を非モーダルコンテキストで表示し、かつ表示をデフォルトで有効にするには、以下のopen属性を使います。

<dialog open>
  Lorem ipsum ....
</dialog>

ただし、このダイアログは「モーダル」に表示されていないため、この方法を使ってもアクセシビリティ機能を利用できません。

🔗 <dialog>を閉じる

モーダルで表示された<dialog>は、JavaScriptを使えば閉じることが可能です。

const modal = document.querySelector("dialog")
modal.showModal()
modal.close()

また、<form>要素でdialogメソッドを使って閉じることも可能です。これは、「閉じる」ボタンを実装する優れた方法です。

<dialog>
  <header>
    <h2>モーダルダイアログ</h2>
    <form method="dialog">
      <button type="submit">閉じる</button>
    </form>
  </header>

  Lorem ipsum...
</dialog>

これで<dialog>要素の基本を説明し終えたので、Hotwireでの利用法を見てみましょう。

🔗 1: モーダルをStimulusで表示する場合

Stimulusは、Hotwireの傘下にあるJavaScriptライブラリです。これを使えば、Stimulusの「コントローラ」にカプセル化されたHTML要素にJavaScriptロジックをアタッチできます。本記事では、StimulusのAPIに関する基本的な知識があることを前提としています。

最初はRailsコントローラとビューから始めましょう。ユースケースとして、連絡先の詳細をモーダルで表示するボタンがあるサポートページを使うことにします。以下のコマンドを実行して、Supportコントローラとshowアクションを生成します。

$ bin/rails generate controller support show

作成したルーティングをユーザーが使いやすいパスに変更します。

# config/routes.rb
Rails.application.routes.draw do
  # ...

  get '/support', to: "support#show"
end

新しく生成されたビューファイル上で、ボタンとダイアログをスケッチします。

<%# app/views/support/show.html.erb %>

<button>
  連絡先の詳細を表示
</button>

<dialog aria-labelledby="modal_title">
  <header>
    <h2 id="modal_title">
      連絡先の詳細
    </h2>
    <form method="dialog">
      <button aria-label="close">X</button>
    </form>
  </header>

  <p>
    Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
    incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
    nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
  </p>
</dialog>

Railsサーバーを実行して、ブラウザで/supportパスを開いてみましょう。ボタンは表示されていますが、現時点では何も動作しません。Stimulusコントローラを作成して接続してみましょう。

🔗 Stimulusコントローラ

以下のジェネレータでStimulusコントローラを作成します。

$ bin/rails generate stimulus modal

ボタンがクリックされたら、<dialog>への参照を取得して、それに対してshowModal()を呼び出す必要があります。コントローラを汎用的に保つため、id要素をparamとして渡します。

// app/javascript/controllers/modal_controller.js

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="modal"
export default class extends Controller {
  show(event) {
    const dialog = document.getElementById(event.params.dialog)
    dialog.showModal()
  }
}

これで、必要なdata-*属性を用いてボタンを装飾できるようになりました。

<%# app/views/support/show.html.erb %>

<button
  data-controller="modal"
  data-action="modal#show"
  data-modal-dialog-param="contact_details_modal">
  連絡先の詳細を表示
</button>

<dialog id="contact_details_modal" aria-labelledby="modal_title">
  <%# ... %>
</dialog>

ページを更新すると、ボタンが機能するようになります。

ここで定型コードを削除するチャンスがあります。modalコントローラは、モーダルを表示するボタンに常に接続する必要があります。data-actionをマークアップから削除して以下のようにコントローラに設定できます。

// app/javascript/controllers/modal_controller.js

// ...
export default class extends Controller {
  connect() {
    this.element.dataset.action = "modal#show"
  }

  show(event) {
    // ...
  }
}

🔗 モーダルにスタイルを追加する

現在のダイアログは飾り気がないので、いくつかスタイルを追加してみましょう。

/* app/assets/stylesheets/application.scss */

dialog {
  width: 80vw;
  margin: auto;

  &::backdrop {
    background: red;
    opacity: 0.2;
  }

  header {
    display: flex;
    align-items: center;

    h2 {
      flex: 0 1 100%;
    }
  }
}

::backdrop疑似要素は、モーダルの背景にスタイルを設定する非常に優れた方法です。

Tabキーを押してページ内のフォーカスを移動してみると、フォーカスが期待通りモーダル内に閉じ込められていることがわかります。スクリーンリーダーでページを確認しておくとなおよいでしょう。

以上の方法は、<dialog>をモーダルで表示する最も手軽な方法です。

次は、モーダルをサーバードリブンで表示する方法、すなわちTurbo Framesを見ていきます。

🔗 2: モーダルをTurbo Frameで表示する場合

Turbo Framesは、Stimulusと同じくHotwire傘下であるTurboライブラリのサブセットです。Turbo Framesを使うと、ナビゲーションの範囲をページの特定の部分に設定し、ページの残りの部分から切り離して更新できるようになります。

Turbo Framesを使うことで、サーバーでレンダリングしたモーダルを表示できます。

🔗 セットアップ

連絡先フォームをモーダルで表示するためのボタンをもう1つ追加しましょう。フォームをレンダリングするには、コントローラとアクションが必要です。

$ bin/rails generate controller support/tickets new create

自動生成されたルーティングを以下のように置き換えます。

Rails.application.routes.draw do
  # ...

  namespace :support do
    resources :tickets, only: [:new, :create]
  end
end

モーダルをレンダリングするにはグローバルなTurbo Framesも必要なので、メインの「アプリケーション」レイアウトに配置します

<%# app/views/layouts/application.html.erb %>

<!DOCTYPE html>
<html>
  <%# ... %>

  <body>
    <%= yield %>

    <%= turbo_frame_tag :remote_modal %>
  </body>
</html>

連絡先フォームを表示するためのリンクをshowビューに追加します。

<%# app/views/support/show.html.erb %>

<button
  data-controller="modal"
  data-modal-dialog-param="contact_details_modal">
  連絡先の詳細を表示
</button>

<%= link_to new_support_ticket_path, data: { turbo_frame: :remote_modal } do %>
  連絡先フォームを表示
<% end %>

<%# ... %>

これで完了です!

🔗 フォームをレンダリングして表示する

「連絡先フォームを表示」リンクをクリックすると、レスポンスにid="remote_modal"<turbo-frame>が含まれ、その内容でグローバルなTurbo Framesが更新されます。

以下のようにフォームをビューに入力します。

<%# app/views/support/tickets/new.html.erb %>

<%= turbo_frame_tag :remote_modal do %>
  <dialog id="contact_form_modal" aria-labelledby="modal_title">
    <header>
      <h2 id="modal_title">
        Contact us
      </h2>
      <form method="dialog">
        <button aria-label="close">X</button>
      </form>
    </header>

    <%= form_with(url: support_tickets_path) do |form| %>
      <%= form.label :message, "Your message" %>
      <%= form.text_area :message, autofocus: true %>

      <%= form.button "Close", value: nil, formmethod: :dialog %>
      <%= form.button "Send" %>
    <% end %>
  </dialog>
<% end %>

これで、フォーカス可能な要素であるテキスト領域がモーダルにできました。アクセシビリティのために、モーダルが表示されるときにデフォルトでフォーカスされる必要があります。これを実現するためには、autofocus属性を使います。

ページを更新し、「連絡先フォームを表示」をクリックしてみます。表示上は何も起きませんが、HTMLを調べると、remote_modal Turbo Framesに<dialog>がレンダリングされていることがわかります。今の段階ではまだ表示していないため、非表示になっています。

open属性でレンダリングしてもよいのですが、モーダルアクセシビリティ機能のない非モーダルコンテキストで表示されるため、このままでは目的を達成できません。

フォームを表示するために別のStimulusコントローラを作成しましょう。

$ bin/rails generate stimulus remote_modal
// app/javascript/controllers/remote_modal_controller.js

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="remote-modal"
export default class extends Controller {
  connect() {
    this.element.showModal()
  }
}

それを以下のようにダイアログに接続します:

<%# app/views/support/tickets/new.html.erb %>

<%= turbo_frame_tag :remote_modal do %>
  <dialog
    id="contact_form_modal"
    aria-labelledby="modal_title"
    data-controller="remote-modal">
    <%# ... %>
  </dialog>
<% end %>

ページを更新して、もう一度連絡先フォームを表示してみてください。今度は動作するはずです。ここではサーバーからモーダルをレンダリングしただけです。

これは非常に便利ですが、モーダルを表示するためだけに新しいStimulusコントローラをわざわざ作成するのは理想的ではありません。別の方法として、Turbo Streamsによるモーダルを使う方法もあります。

🔗 3: モーダルをTurbo Streamsで表示する場合

Turbo StreamsもTurboのサブセットです。Turbo Streamsを使うと、ページに対してきめ細かくターゲットを絞った更新を行えるようになります。
デフォルトでは7つのCRUDアクションが含まれていますが、アプリケーション内でさらにアクションを追加することも可能です。

次は、過去記事でも使った、<dialog>をレンダリングして表示するshow_remote_modalアクションを作成します。

🔗 カスタムアクションを作成する

以下のコマンドを実行して、すべてのカスタムストリームアクションを配置するフォルダを作成します。

$ mkdir app/javascript/stream_actions
$ touch app/javascript/stream_actions/index.js

ストリーミング用のアクションを書くためのファイルも作成します。

$ touch app/javascript/stream_actions/show_remote_modal.js

Stream Actionsをアプリケーションにインポートします。

// app/javascript/stream_actions/index.js

import "./show_remote_modal"
// app/javascript/application.js

// ...
import "stream_actions"

Railsでimportmapを使っている場合は、設定を更新した後でサーバーを再起動する必要があります。

# config/importmap.rb

# ...
pin_all_from "app/javascript/stream_actions", under: "stream_actions"

グローバルなリモートモーダルコンテナを、Turbo FrameではなくHTML要素に変更します。

<%# app/views/layouts/application.html.erb %>

<!DOCTYPE html>
<html>
  <%# ... %>

  <body>
    <%= yield %>

    <remote-modal-container></remote-modal-container>
  </body>
</html>

カスタムのストリームアクションは以下のように実装できます。

// app/javascript/stream_actions/show_remote_modal.js

Turbo.StreamActions.show_remote_modal = function() {
  const container = document.querySelector("remote-modal-container")
  container.replaceChildren(this.templateContent)
  container.querySelector("dialog").showModal()
}

上のスニペットのthisは、StreamElementを参照します。これは、<turbo-stream>の基盤となるカスタム要素です。templateContentゲッターはこの要素で定義されます。

🔗 アクションをヘルパー経由で使う

これはカスタムアクションなので、これを使うにはRailsヘルパーを手動で作成する必要があります。

$ bin/rails generate helper TurboStreamActions
# app/helpers/turbo_stream_actions.rb

module TurboStreamActionsHelper
  def show_remote_modal(&block)
    turbo_stream_action_tag(
      :show_remote_modal,
      template: @view_context.capture(&block)
     )
  end
end

Turbo::Streams::TagBuilder.prepend(TurboStreamActionsHelper)

これで、このヘルパーをビューで使えるようになりました。

<%# app/views/support/tickets/new.html.erb %>

<%= turbo_stream.show_remote_modal do %>
  <dialog id="contact_form_modal" aria-labelledby="modal_title">
    <header>
      <h2 id="modal_title">
        Contact us
      </h2>
      <form method="dialog">
        <button aria-label="close">X</button>
      </form>
    </header>

    <%= form_with(url: support_tickets_path) do |form| %>
      <%= form.label :message, "Your message" %>
      <%= form.text_area :message, autofocus: true %>

      <%= form.button "Close", value: nil, formmethod: :dialog %>
      <%= form.button "Send" %>
    <% end %>
  </dialog>
<% end %>

data-controller属性はもう不要になったので、忘れず削除しましょう。実は、コントローラ自体も削除できます。

$ rm app/javascript/controllers/remote_modal_controller.js

また、テンプレート名を変更してTurbo Streamsとしてレンダリングされるようにしておく必要もあります

$ mv \
    app/views/support/tickets/new.html.erb\
    app/views/support/tickets/new.turbo_stream.erb

Turbo StreamsはGETリクエストに対してデフォルトで無効になっているため、リンクに対して手動で有効にする必要があります。

<%# app/views/support/show.html.erb %>

<%# ... %>

<%= link_to new_support_ticket_path, data: { turbo_stream: true } do %>
  連絡先フォームを表示
<% end %>

<%# ... %>

ページを更新して「連絡先フォームを表示」をクリックすると、変更前と同じように動作するはずですが、今度はカスタムストリームアクションでレンダリングされる点が異なります。

🔗 まとめ

本記事では、モーダルをHotwire流に表示する3つの異なる方法(Stimulus、Turbo Frames、Turbo Streams)について解説しました。さらに重要なのは、このモーダルはアクセシビリティを主に考慮して表示されることです。

Webは誰もが利用できるべきであり、Web開発者として、Webサイトをアクセシビリティ対応にするために努力することが重要です。

Basecampがアクセシビリティガイドを公開しているので、アクセシビリティの要点を学べる素晴らしいリソースとして利用できます。

また、StimulusTurboのドキュメントもチェックして、本記事で使われているAPIなどひととおりの機能に慣れておくことをおすすめします。

本記事はAppSignal's blogで最初に公開されました。

関連記事

HTMLDialogElement: showModal() はHTMLの長年の課題を解決する期待の星かと思ったら実際はだめな子

Rails: HotwireCombobox gemが素晴らしすぎるという話(翻訳)

Rails: Turbo StreamsとTurbo Framesの使い分けを理解する(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。