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

Rails: 最新のブラウザ機能で「JavaScriptなし」のスタイル付き確認ダイアログを構築する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

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

以下は本記事を元にしたスタイル・アニメーション付き確認ダイアログです。ダイアログの外側をクリックしてもEscキーを押してもダイアログが閉じます。これらの機能はすべてJavaScriptなしで実現できています。

Rails: 最新のブラウザ機能で「JavaScriptなし」のスタイル付き確認ダイアログを構築する(翻訳)

原注

これはStephen Margheimとのコラボ記事です。StephenはHigh Leverage Railsの作者であり、同サイトではシンプルなSQLite・HTML・CSSのパワーを駆使して高品質なRailsアプリケーションを構築する方法を動画で学べます。

Turboのdata-turbo-confirm属性は、Webアプリで確認ダイアログをさくっと作るのに便利ですが、この属性がトリガーするブラウザネイティブのconfirm()のダイアログは、さすがに古臭くて浮いている印象があります。

アプリの最新デザインにふさわしい確認ダイアログを表示しようとする場合、従来推奨されてきたのは以下のようにJavaScriptをどっさり使うアプローチばかりで、ダイアログの表示・非表示切り替えや、イベントリスナーによるキーボード入力の処理、トリガーやモーダルダイアログの振る舞いの細かな調整などをStimulusコントローラであれこれ制御するのが通例でした。

しかし最近のブラウザのアップデートで状況が激変しました。Chrome 131やSafari 18.4で導入されたInvoker(呼び出し)コマンドcommand="コマンド名")のおかげで、ダイアログの制御を宣言的に書けるようになったのです。

参考: 呼び出しコマンド API - Web API | MDN

そこに@starting-styleのアニメーションを組み合わせれば、JavaScript一切不要のアニメーション付きの美麗な確認ダイアログを構築できます。

機能 実現方法
ダイアログを表示する ボタンにcommand="show-modal"を書くだけ
ダイアログを閉じる キャンセルボタンにcommand="close"を書くだけ
エスケープキーでダイアログを閉じる (ブラウザ組み込みの機能)
ダイアログ外のクリックでダイアログを閉じる closedby="any"属性を指定するだけ
アニメーション開始 CSSの@starting-styleルールで指定するだけ
アニメーション終了 displayトランジションでallow-discreteを指定するだけ

アプリでユーザーが項目を削除しようとしたときに確認ダイアログを表示したいとします。最新のブラウザ機能を活用して確認ダイアログをJavaScriptなしで構築するには、以下のように書きます。

<button type="button" commandfor="delete-item-dialog" command="show-modal">
  Delete this item
</button>

<dialog id="delete-item-dialog" closedby="any" role="alertdialog"
        aria-labelledby="dialog-title" aria-describedby="dialog-desc">
  <header>
    <hgroup>
      <h3 id="dialog-title">Delete this item?</h3>
      <p id="dialog-desc">Are you sure you want to permanently delete this item?</p>
    </hgroup>
  </header>

  <footer>
    <button type="button" commandfor="delete-item-dialog" command="close">
      Cancel
    </button>
    <%= button_to item_path(item), method: :delete do %>
      Delete item
    <% end %>
  </footer>
</dialog>

最新のcommand属性を使うと、実行すべき操作をブラウザに指示できます。操作の対象となる要素はcommandfor属性にidで指定します。

<button type="button" commandfor="delete-item-dialog" command="show-modal">
  Delete this item
</button>

たとえばボタンにcommand="show-modal"という属性を追加しておけば、ユーザーがボタンをクリックしたときに表示対象のダイアログでshowModal()メソッドが呼び出されます。

キャンセルボタンにもcommand="close"属性を追加しておけば、クリックしたときにcloseメソッドが呼び出されます。

    <button type="button" commandfor="delete-item-dialog" command="close">
      Cancel
    </button>

このキャンセルボタンにはtype="submit"ではなく、type="button"が設定されていることにご注意ください。つまり、このキャンセルボタンをクリックしても、フォームが送信される心配はまったくないということです。

showModal()メソッドで表示されたモーダルダイアログは、何も指定しなくてもEscキーを押すだけでブラウザが自動的に閉じてくれます。

さらにclosedby="any"属性も指定しておけば、ダイアログの外側の背景のどこをクリックしても自動的に閉じるという待望の機能が実現できます。

<dialog id="delete-item-dialog" closedby="any" role="alertdialog"
        aria-labelledby="dialog-title" aria-describedby="dialog-desc">

aria-labelledby属性はダイアログの見出し要素のidを、aria-describedby属性はダイアログの説明文要素のidをそれぞれ指定できます。これらで指定した見出しや本文はスクリーンリーダーによって即座に読み上げられるので、ユーザーはその音声を元にダイアログに対応できるようになります。

さらに、ダイアログ要素にrole="alertdialog"属性を指定しておけば、そのダイアログがユーザーが必ず対応しなければならない重要なものであることをブラウザに伝えられます(訳注: ブラウザは基本的にOSのアクセシビリティAPIとしてダイアログを表示し、スクリーンリーダーがそれを受けてダイアログの内容を読み上げます)。

🔗 デモ

このダイアログをさまざまな方法で閉じられることを、以下のデモで試せます(訳注: 原文のデモをCodePenに移植しました)。

以下のどの方法でもダイアログが閉じます。

  • [Cancel]ボタンをクリックする
  • [Delete Item]ボタンをクリックする
  • Escキーを押す
  • ダイアログの外側の領域をクリックする

ここでご注目いただきたいのは、returnValueの値が "confirm"になるのは、[Delete Item]ボタンの場合のみであり、それ以外の場合は決して"confirm"にならないことです。

宣言的なHTMLだけでこれほど充実した機能が使えるようになるので、とても気に入っています。

🔗 @starting-styleでアニメーションも追加できる

確認ダイアログのデザインを洗練させるために、スムーズな表示・非表示のトランジションを追加しましょう。
CSSの@starting-styleアットルール)とallow-discrete(キーワード値)を使えば、CSSだけでダイアログの表示・非表示をアニメーション化できます。

@starting-styleルールは、要素が最初に表示されるときの初期状態を定義します。ダイアログにルールが指定されていない場合は、ブラウザで即座に最終的な状態が表示されます。
ダイアログでたとえば以下のようにルールを指定すると、ブラウザ上でopacity: 0; scale: 0.95からopacity: 1; scale: 1へトランジションが行われます。

  @starting-style {
    opacity: 0;
    scale: 0.95;
  }

ダイアログを閉じるときのアニメーションでは、displayoverlayのトランジションにallow-discreteを指定する必要があります。

  transition:
    opacity 0.2s ease-out,
    scale 0.2s ease-out,
    overlay 0.2s ease-out allow-discrete,
    display 0.2s ease-out allow-discrete;

CSSプロパティは、opacity(透明度)を0.5にしたり、色をブレンドしたりできることからもわかるように、ほとんどの場合、中間値も指定可能です。

しかしCSSのdisplayプロパティは、たとえばnoneblockの中間値が存在しないことからわかるように、離散的なプロパティです。つまり従来のdisplayは、歴史的にアニメーションを指定できなかったのです。

CSSでallow-discreteを指定すると、そうした離散的なプロパティにもトランジションのタイミングを適用可能になります。

ダイアログを閉じるときのアニメーションであれば、要素が表示された状態からアニメーションを実行すると、アニメーション中は要素を表示したままにし、アニメーションが完了したときにはじめてdisplay: none に切り替わります。

これはCSSのoverlayプロパティについても同様です。overlayプロパティのトランジションにease-out allow-discreteを指定すると、アニメーションが終わるまではダイアログをtop layer(最上位レイヤ)に維持するようになります。

dialog {
  opacity: 1;
  scale: 1;

  transition:
    opacity 0.2s ease-out,
    scale 0.2s ease-out,
    overlay 0.2s ease-out allow-discrete,
    display 0.2s ease-out allow-discrete;

  @starting-style {
    opacity: 0;
    scale: 0.95;
  }
}

dialog:not([open]) {
  opacity: 0;
  scale: 0.95;
}

dialog::backdrop {
  background-color: rgb(0 0 0 / 0.5);
  transition:
    background-color 0.2s ease-out,
    overlay 0.2s ease-out allow-discrete,
    display 0.2s ease-out allow-discrete;

  @starting-style {
    background-color: rgb(0 0 0 / 0);
  }
}

dialog:not([open])::backdrop {
  background-color: rgb(0 0 0 / 0);
}

🔗 ブラウザでのサポート状況

機能 Chrome Safari Firefox Can I Use?
command 135以降 26.2以降 144以降 リンク
commandfor 135以降 26.2以降 144以降 リンク
@starting-style 117以降 17.5以降 129以降 リンク
closedby 134以降 未対応 141以降 リンク

Safariでのclosedbyサポートはまだ保留中の状態です。production環境でSafariに対応したい場合は、dialog-closedby-polyfillポリフィルを追加してください。

fractaledmind/dialog-closedby-polyfill - GitHub

古いブラウザでinvokerコマンドのサポートが必要な場合は、それ用のinvokers-polyfillポリフィルも利用できます。

keithamus/invokers-polyfill - GitHub

どちらのポリフィルもサイズが小さく、ネイティブサポートがない場合にのみ実行されます。

🔗 Turboの確認ダイアログに統合する

さて、スタイリング可能なネイティブダイアログを使いながら、Turboのdata-turbo-confirm属性も使いたい場合はどうすればよいでしょうか?

実は、TurboではまさにそのためのTurbo.config.forms.confirmが提供されています。Mikael Henrikssonがこれに関する良記事を書いており、Chris OliverによるGoRails動画もあります。

参考: mhenrixon | Turbo confirm
参考: Custom Turbo Confirm Modals with Hotwire in Rails | GoRails

まず、レイアウトにダイアログ用のテンプレートを追加しておきます。もちろん、アプリで使われているCSSをこのレイアウトに設定して自由にスタイルを設定できます。

<%# app/views/layouts/application.html.erb %>
<dialog id="turbo-confirm-dialog" closedby="any"
        aria-labelledby="turbo-confirm-title" aria-describedby="turbo-confirm-message">
  <header>
    <hgroup>
      <h3 id="turbo-confirm-title">Confirm</h3>
      <p id="turbo-confirm-message"></p>
    </hgroup>
  </header>

  <footer>
    <button type="button" commandfor="turbo-confirm-dialog" command="close">
      Cancel
    </button>
    <form method="dialog">
      <button type="submit" value="confirm">
        Confirm
      </button>
    </form>
  </footer>
</dialog>

[Confirm]ボタンは、フッターの<form method="dialog">フォーム内にあるtype="submit"です。

  <footer>
    ...
    <form method="dialog">
      <button type="submit" value="confirm">
        Confirm
      </button>
    </form>
  </footer>

このフォームが送信されると、ブラウザはダイアログを閉じて、returnValueプロパティにボタンのvalue属性を設定します。この方法によって、どのボタンが押されたかを検出します。そのためにJavaScriptイベントを調整する必要はありません。

次に、Turbo側を設定します。

const dialog = document.getElementById("turbo-confirm-dialog")
const messageElement = document.getElementById("turbo-confirm-message")
const confirmButton = dialog?.querySelector("button[value='confirm']")

Turbo.config.forms.confirm = (message, element, submitter) => {
  // Fall back to native confirm if dialog isn't in the DOM
  if (!dialog) return Promise.resolve(confirm(message))

  messageElement.textContent = message

  // Allow custom button text via data-turbo-confirm-button
  const buttonText = submitter?.dataset.turboConfirmButton || "Confirm"
  confirmButton.textContent = buttonText

  dialog.showModal()

  return new Promise((resolve) => {
    dialog.addEventListener("close", () => {
      resolve(dialog.returnValue === "confirm")
    }, { once: true })
  })
}

上のJavaScriptが行っているのは、以下の3つだけです。

  1. 表示するメッセージテキストを設定する
  2. ボタン名をカスタマイズする(指定されている場合)
  3. ダイアログを表示する

それ以外の「ボタンをクリックしたら閉じる」「Escキーで閉じる」「ダイアログの外側をクリックしたら閉じる」は、すべてプラットフォーム側で処理されます。

ブラウザネイティブのconfirm()にフォールバックするようになっているので、ダイアログの要素が見つからない場合(レイアウトが異なる場合やエラーページの場合など)でもアプリは引き続き動作します。

Turbo.config.forms.confirmで指定する関数は、trueまたはfalse(それぞれ「進む」と「キャンセル」に対応)のいずれかに解決されるPromiseを返すことが期待されます。
この関数は、「message(確認メッセージ)」「elementdata-turbo-confirm属性を持つ要素)」「submitter(送信を行う要素)」の3つの引数を受け取ります。

ハンドラーではcloseイベントをリッスンし、returnValueをチェックします。ハンドラーを1回記述し、このダイアログをレイアウトに1つ追加しておけば、アプリ内のあらゆるdata-turbo-confirmで利用されます。

以下のようにdata-turbo-confirm-button属性を使うと、ボタンに表示するテキストをトリガーごとにカスタマイズできます。

<%= button_to item_path(item),
              method: :delete,
              data: {
                turbo_confirm: "Are you sure you want to delete this item?",
                turbo_confirm_button: "Delete item"
              } do %>
  Delete
<% end %>

こうすることで、お仕着せの汎用[Confirm]ボタンの代わりに、状況にふさわしい[Delete item]ボタンを確認ダイアログで表示できるようになります。UXも改善され、ダイアログで行う操作もユーザーにとって明確になります。

🔗 補足: 背景のスクロールを止める

モーダルダイアログの表示中は、ページをスクロールできないようにするのが一般的な要件です。

body:has(dialog:modal) {
  overflow: hidden;
}

modal擬似クラスは、showModal()で表示したダイアログにマッチするようになっています。
これに:has()疑似クラス関数を上のように組み合わせれば、モーダルダイアログが表示中の場合にのみセレクタがbodyを対象とするようになり、ダイアログを表示中はスクロールが無効になります。ダイアログを閉じれば再びスクロール可能になります。これらはブラウザによって制御されるので、JavaScriptは使いません。

関連記事

Rails: Turbo Frameの読み込みプログレス表示をCSSだけで実現する(翻訳)

Railsの技: TailwindスタイルのCSSトランジションをStimulusJSで行う(翻訳)


CONTACT

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