モーダルをLaravel+Alpine.js+TailwindCSSで作成する

プログラミング

2024年9月24日

Webのモーダルって、それっぽいものであればかなりいい加減に作成できちゃう。
私自身Alpine.jsのx-showとかでかなりいい加減に作ってしまう人なので、ここにモーダルについてまとめておく。

LaravelのBlade、Alpine.js、TailwindCSSを使うので注意。

参考元

モーダルの要件

そもそも、モーダルに求めるものは何か。

ここでは以下を満たすものをモーダルとする

  1. 背景とダイアログで構成される
  2. 最前面にダイアログが表示される
  3. キーボードとマウスのどちらでも閉じられる
  4. 背景ドキュメントへのアクセスをブロックする(クリック・選択・フォーカスはバックドロップを貫通しない)
  5. 背景ドキュメントのスクロールをブロックする

Alpine.jsのx-showとか、CSSのz-indexとかで雑に作ると2,4,5番らへんが満たされないがち。

完成形

見た目

今回できるものは以下のような感じ

これは、以下の要件をクリアしている。

  1. 背景とダイアログで構成される
  2. 最前面にダイアログが表示される
  3. キーボードとマウスのどちらでも閉じられる
  4. 背景ドキュメントへのアクセスをブロックする(クリック・選択・フォーカスはバックドロップを貫通しない)
  5. 背景ドキュメントのスクロールをブロックする

また、背景をクリックして閉じれるのと、スクロールバーによるカクツキ防止も追加している。

コード

コードは以下

[modal.blade.php]

@extends("layouts.app")

@section("content")
    <div class="max-w-5xl p-2 mx-auto mt-4" x-data>
        <button class="px-4 bg-white border border-zinc-500 rounded hover:bg-zinc-200" @click.stop="$refs.modal.showModal()">
            モーダル表示
        </button>
        @for($i=0; $i++, $i<100;)
            <p>その日二人の間に坐っている下女を次へ立たせて、私を呼ぶ大きな兄の声がした。私の行ったのは、無理という点から見てじゃない。そうして多くの卑怯な人と同じ程度に、重く見ていなかった。そのくせ話し始める時は、もうあの話が済んだ頃だとも褒めてくれました。ただこういう風になったのです。</p>
        @endfor
        <dialog x-ref="modal" class="w-2/3 h-2/3 rounded backdrop:bg-zinc-800/50">
            <div class="bg-red-100 p-4" @click.outside="$refs.modal.close()">
                <button class="px-4 bg-white border border-zinc-500 rounded hover:bg-zinc-200" @click="$refs.modal.close()">閉じる</button>
                @for($i=0; $i++, $i<100;)
                    <p>その日二人の間に坐っている下女を次へ立たせて、私を呼ぶ大きな兄の声がした。私の行ったのは、無理という点から見てじゃない。そうして多くの卑怯な人と同じ程度に、重く見ていなかった。そのくせ話し始める時は、もうあの話が済んだ頃だとも褒めてくれました。ただこういう風になったのです。</p>
                @endfor
            </div>
        </dialog>
    </div>
@endsection

[layouts/app.blade.php]

...
<div id="container">
   @yield('content')
</div>
...

[app.css]

/* モーダル時に背景のスクロールを防ぐ & モーダル時にスクロールを無効化*/
body:has(dialog[open]) {
    overflow: hidden;
}
body:has(dialog[open]) div#container{
    overflow: hidden;
    scrollbar-gutter: stable;
}

app.cssはlayouts/app.blade.phpで読み込んでいる。

dialog要素

dialog要素はまぁまぁ新しめの要素。
mdn:<dialog>: ダイアログ要素

これを上手く使うことで

  • 背景とダイアログで構成される
  • 最前面にダイアログが表示される
  • 背景ドキュメントへのアクセスをブロックする

を満たせる。

dialog要素のopen属性

このdialog要素はopen属性がtrueかfalseかで表示されるか否かが決まる。
初期はfalseっぽいので何もしなければ最初はdialogが表示されない。

open属性を直接付け替えることもできるんだけど、基本このopen属性の操作はshowModal()close()メソッドを使うべき。

理由は、これらのメソッドにopen属性付け替え以外の大きな役割があり、それがモーダルの要件を満たすのに重要だから。

「背景とダイアログで構成される」を満たす

dialog要素は、showModal()を使うことでTop Layerで表示される

Top Layerには何個か特徴があって、その中に::backdrop疑似要素がある。

この::backdropは最上位レイヤーの背景として扱うことができて、user agentで以下のCSSを付けることが義務付けられている

::backdrop {
  position: fixed;
  inset: 0;
}

そのため、この::backdropに半透明の黒とかの背景色を付ければ背景の完成。
先のコード例でいう

<dialog x-ref="modal" class="w-2/3 h-2/3 rounded backdrop:bg-zinc-800/50">

の「backdrop:bg-zinc-800/50」部分が該当する。

これで背景とダイアログで構成されるを満たした。

「最前面にダイアログが表示される」を満たす

先の通り、dialogをshowModal()で開くことによってdialogは最上位レイヤーになる。

最上位レイヤーはその名の通り、全てのレイヤーの中で一番上に存在するもの。
そのため、showModal()でdialogを開けば「最前面にダイアログが表示される」を満たす。

「背景ドキュメントへのアクセスをブロックする」を満たす

つまり、フォーカスとかがダイアログ以外に選択されないようにするということ。

これは、mdnのdialogドキュメントを引用すると

モーダルダイアログを実装する際には、<dialog> とそのコンテンツ以外は inert 属性を使って不活性化する必要があります。<dialog> を HTMLDialogElement.showModal() メソッドで使用した場合、この動作はブラウザーが提供します。

とのこと。

つまり、showModal()メソッドで開けばdialogとそのコンテンツ以外にinert属性を自動で付けてくれるので背景ドキュメントへのアクセスをブロックしてくれる。

「キーボードとマウスのどちらでも閉じられる」を満たす

dialog要素をshowModal()で開けばescキーで閉じられるようになるので、キーボードで閉じられるは自動で満たす。

マウスでどう閉じるかなんだけど、

<dialog x-ref="modal"

のようにdialogにalpine.jsのx-refを付けてあげ、

@click="$refs.modal.close()

を適当なボタンとかに付けてあげればおk。

「背景ドキュメントのスクロールをブロックする」を満たし、スクロールバーのかくつきを防止する

背景のスクロールを防止するのはbodyとかにoverflow: hidden;を付けてあげることで解決。

ここではhas(dialog[open])を付けることで、bodyがopen属性がtrueのdialogを持つときだけ適用されるようにしている。

body:has(dialog[open]) {
   overflow: hidden;
}

また、スクロールバーのかくつきも防止する。

スクロールバーのかくつきとは、以下gifのようなこと。

overflow: hiddenを付けたことでdialogを開いたときbodyのスクロールバーが無くなってしまい、その分bodyの要素が右に寄ってかくつく現象。

これはscrollbar-gutterというCSSで対策が可能。
scrollbar-gutterという名前通り、ボウリングのガターのようなスクロールバーのガター部分を操作できる。
scrollbar-gutter: stableを付けることで、スクロールバーが無くてもガター部分だけ表示できる。
また、スクロールバーが無い状態でのガターはパディングとして扱われる

ただし、このCSSには現時点で注意点が2つある。

1つ目は、safariで使えないということ。
対応してないならしょうがないね。

2つ目が、htmlやbodyに対するscrollbar-gutter: stableは無効化されるということ。

htmlやbodyに対するscrollbar-gutter: stableは思った通り動かない

W3Cのドキュメントを一部引用すると

As for the overflow property, when scrollbar-gutter is set on the root element, the user agent must apply it to the viewport instead, and the used value on the root element itself is scrollbar-gutter: auto. However, unlike the overflow property, the user agent must not propagate scrollbar-gutter from the HTML body element.

https://drafts.csswg.org/css-overflow/#scrollbar-gutter-property

とのこと。

正直、あまりCSSに詳しくないのでよくわかんないんだけど、html要素(root element)にscrollbar-gutterが付いている場合、代わりにuser agentはviewportにscrollbar-gutter: autoを付けなきゃいけないみたい。

だから、html要素に付けたらダメっぽい?

また、body要素に付けた場合は伝播禁止らしくscrollbar-gutter: stableをbodyに付けても何も起きない。

私があまりCSSを好かない理由がこういうところにあると思う。

どう回避するか

じゃあどうするかなんだけど、私は以下のようにした。

body:has(dialog[open]) {
    overflow: hidden;
}
body:has(dialog[open]) div#container{
    overflow: hidden;
    scrollbar-gutter: stable;
}

divであればstableが効くので、テンプレートのメイン要素divを

<div id="container">
   @yield('content')
</div>

のようにして、無理やり適応している感じ。

scrollbar-gutter: stableはその要素のoverflowがhidden,auto,scrollじゃないと機能してくれないっぽいのでhiddenも付けてる。

とりあえずこんな感じで全ての要件を満たした。

背景をクリックするとモーダルを閉じる

背景をクリックすることでモーダルを閉じるのをどう実装したかなんだけど、alpine.jsのoutsideを利用している。

<div class="bg-red-100 p-4" @click.outside="$refs.modal.close()">

かなり便利。ありがとうalpine.js。