dialogタグを使ってモーダルを実装する

モーダルの実装は何かと大変です。便利なライブラリもたくさん出ていますが、今回は`<dialog>` を使ったモーダルの実装を紹介します。

デモはこちらです。
https://codepen.io/mtoutside/pen/jOXbwPz

<p class="codepen" data-height="300" data-default-tab="html,result" data-slug-hash="jOXbwPz" data-user="mtoutside" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/mtoutside/pen/jOXbwPz">
  &lt;dialog&gt; demo with fadeAnimation and scroll lock</a> by mtoutside (<a href="https://codepen.io/mtoutside">@mtoutside</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

## HTML

```html
<dialog class="dialog js-dialog is-hidden">
  <div class="dialog__inner js-dialog__inner">
    <h3 class="dialog__title">dialog内タイトル</h3>
    <p class="dialog__text">dialog内のテキスト</p>
    <p class="dialog__text">dialog内のテキスト</p>
    <button class="btn btn--close js-btn--close" type="button">閉じる</button>
  </div><!-- /.dialog_inner -->
</dialog>

<button class="btn btn--open js-btn--open" type="button">モーダル</button>
```

## css

<details><summary>cssはこちら</summary>

```scss
@keyframes fadeIn {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}

@keyframes fadeOut {
  from {
    opacity: 1;
  }

  to {
    opacity: 0;
  }
}

.btn {
  cursor: pointer;
}

dialog::backdrop {
  background: rgba(94, 94, 94, 0.5);
  backdrop-filter: blur(4px);
}

body.is-modal {
  overflow: hidden;
}

dialog.is-hidden {
  display: none;
}

.dialog {
  display: block;
  position: fixed;
  top: 0;
  bottom: 0;
  left: 0;
  transform-origin: center;
  padding: 0;
  min-width: 600px;
  margin: auto;
  animation-name: fadeOut;
  animation-fill-mode: forwards;
  animation-duration: 200ms;
  animation-timing-function: ease-out;
}

.dialog[open] {
  animation-name: fadeIn;
  animation-fill-mode: forwards;
  animation-duration: 400ms;
  animation-timing-function: ease-out;

  ::backdrop {
    animation-name: fadeIn;
    animation-fill-mode: forwards;
    animation-duration: 200ms;
    animation-timing-function: ease-out;
  }
}

.dialog__inner {
  padding: 16px;
}

.dialog__text {
  margin: 500px 0;
}
```

</details>

モーダルオープン時の背景は`::backdrop`を使って設定します。


## JavaScript
classを使用しています。

```javascript
/**
 * モーダルダイアログを管理するためのユーティリティクラスです。
 */
class ModalManager {
  /**
   * ModalManager のインスタンスを作成します。
   *
   * @param {HTMLElement} modal - モーダルダイアログ要素。
   * @param {HTMLElement} modalInner - モーダルの内部コンテンツコンテナ要素。
   * @param {HTMLElement} closeTrigger - モーダルを閉じるための要素。
   * @param {HTMLElement} openTrigger - モーダルを開くための要素。
   */
  constructor(modal, modalInner, closeTrigger, openTrigger) {
    this.modal = modal;
    this.modalInner = modalInner;
    this.openTrigger = openTrigger;
    this.closeTrigger = closeTrigger;
    this.body = document.body;
    this.MODAL_CLASS = "is-modal";
    this.HIDDEN_CLASS = "is-hidden";
    this.scrollbarWidth = window.innerWidth - document.body.scrollWidth;
  }

  /**
   * 指定されたダイアログ内のすべてのアニメーションが完了するのを待ちます。
   *
   * @param {HTMLDialogElement} dialog - アニメーションを含むダイアログ要素。
   * @returns {Promise<Array<PromiseSettledResult>>} 各アニメーションの完了状態ごとに解決される配列を返す Promise です。
   */
  async waitDialogAnimation(dialog) {
    return Promise.allSettled(
      Array.from(dialog.getAnimations()).map(animation => animation.finished)
    );
  }

  // モーダルを開く
  openModal() {
    this.body.classList.add(this.MODAL_CLASS);
    this.modal.classList.remove(this.HIDDEN_CLASS);
    this.body.style.paddingRight = this.scrollbarWidth + "px";
    this.modal.showModal();
    this.modal.scrollTo(0, 0);
  }

  // モーダルを閉じる
  closeModal() {
    this.body.classList.remove(this.MODAL_CLASS);
    this.modal.close();
  }

  // キャンセル(esc押下)されたとき
  onModalCancel() {
    this.body.classList.remove(this.MODAL_CLASS);
  }

  // モーダルの背景を押されたとき
  onModalClick(e) {
    if (!e.target.closest(this.modalInner)) {
      this.body.classList.remove(this.MODAL_CLASS);
      this.modal.close();
    }
  }

  // modalのcloseイベント
  async onModalClose() {
    this.body.style.paddingRight = "";
    await this.waitDialogAnimation(this.modal);
    this.modal.classList.add(this.HIDDEN_CLASS);
  }

  // 初期化 イベントリスナー設定
  init() {
    this.openTrigger.addEventListener("click", () => this.openModal());
    this.closeTrigger.addEventListener("click", () => this.closeModal());
    this.modal.addEventListener("cancel", () => this.onModalCancel());
    this.modal.addEventListener("click", (e) => this.onModalClick(e));
    this.modal.addEventListener("close", () => this.onModalClose());
  }
}


// モーダルの設定
document.addEventListener("DOMContentLoaded", () => {
  const modal = document.querySelector(".js-dialog");
  const modalInner = ".dialog__inner";
  const openTrigger = document.querySelector(".js-btn--open");
  const closeTrigger = document.querySelector(".js-btn--close");

  const modalManager = new ModalManager(modal, modalInner, closeTrigger, openTrigger);
  modalManager.init();
});

```

### モーダルを開く

```javascript
// モーダルを開く
  openModal() {
    this.body.classList.add(this.MODAL_CLASS);
    this.modal.classList.remove(this.HIDDEN_CLASS);
    this.body.style.paddingRight = this.scrollbarWidth + "px";
    this.modal.showModal();
    this.modal.scrollTo(0, 0);
  }
```

スクロールロックのため`body`にクラスを付与しています。モーダルに付与していた非表示用のクラスを消します。
デフォルトの`<dialog>` ではモーダルオープン時のスクロールロックが実装されていません。なので今回はクラスの付け替えで対応しています。スクロールロックの仕方は他にもあるかと思うので、適宜別の方法で実装してもよいかと思います。

スクロールバーがあれば、スクロールバーの幅分パディングを設定します。
モーダル内がスクロールできる場合、`this.modal.scrollTo(0, 0);` で一番上に戻します。この設定がないと前回開いたモーダルのスクロール位置のままになってしまいます。

### モーダルを閉じる
```javascript
// モーダルを閉じる
  closeModal() {
    this.body.classList.remove(this.MODAL_CLASS);
    this.modal.close();
  }

  // キャンセル(esc押下)されたとき
  onModalCancel() {
    this.body.classList.remove(this.MODAL_CLASS);
  }

  // モーダルの背景を押されたとき
  onModalClick(e) {
    if (!e.target.closest(this.modalInner)) {
      this.body.classList.remove(this.MODAL_CLASS);
      this.modal.close();
    }
  }

  // modalのcloseイベント
  async onModalClose() {
    this.body.style.paddingRight = "";
    await this.waitDialogAnimation(this.modal);
    this.modal.classList.add(this.HIDDEN_CLASS);
  }
```

スクロールロック用に`body`につけたクラスを削除します。 キャンセルされた（`esc`キーが押された）場合のイベント設定には`onModalCancel()`を使います。

デフォルトでは背景を押してもモーダルは閉じません。背景クリックでも消せた方がユーザビリティがいいかと思うので設定します。
`onModalClick(e)`で`<dialog class="dialog js-dialog is-hidden"></dialog>`以外が押されたらモーダルを閉じます。`closest()`を使うことで指定した要素（この場合`<div class="dialog__inner js-dialog__inner">`）の親を対象にできます。

## モーダルのアニメーション JS版
モーダルをふわっと消すようなアニメーションを設定したい場合、cssでアニメーションを設定しても、dialog要素ごと消えてしまうのでうまく設定できません（**※Chrome 117 以降からcssだけで設定できるようになりました。後述**）。ここではasync/awaitを使ってアニメーションの終了イベントを取得し、アニメーションが終了したら非表示クラスを付与して消す方法をとります。

```javascript
/**
   * 指定されたダイアログ内のすべてのアニメーションが完了するのを待ちます。
   *
   * @param {HTMLDialogElement} dialog - アニメーションを含むダイアログ要素。
   * @returns {Promise<Array<PromiseSettledResult>>} 各アニメーションの完了状態ごとに解決される配列を返す Promise です。
   */
  async waitDialogAnimation(dialog) {
    return Promise.allSettled(
      Array.from(dialog.getAnimations()).map(animation => animation.finished)
    );
  }
```

async関数でアニメーションの終了を取得します。

モーダルクローズ時にアニメーションの終了を取得し、アニメーションが終了したら非表示クラスを付与。また、`body`につけていたパディングも消しておきます。

```javascript
// modalのcloseイベント
  async onModalClose() {
    this.body.style.paddingRight = "";
    await this.waitDialogAnimation(this.modal);
    this.modal.classList.add(this.HIDDEN_CLASS);
  }
```

モダールの設定は以下のように行います。
```javascript
// モーダルの設定
document.addEventListener("DOMContentLoaded", () => {
  const modal = document.querySelector(".js-dialog");
  const modalInner = ".dialog__inner";
  const openTrigger = document.querySelector(".js-btn--open");
  const closeTrigger = document.querySelector(".js-btn--close");

  const modalManager = new ModalManager(modal, modalInner, closeTrigger, openTrigger);
  modalManager.init();
});
```
`DOMContentLoaded`はDOMが読み込まれた後に実行したいだけなので、状況に応じて対応してもらって大丈夫です。
- モーダルのタグ
- モーダルの中身のタグ
- 開くボタン
- 閉じるボタン

の4つを指定して、インスタンスを生成します。生成したインスタンスで`init()`を実行してもらえればOKです。

## CSSのみでモーダルをアニメーションさせる

デモはこちら。

<p class="codepen" data-height="300" data-default-tab="html,result" data-slug-hash="NWeYVRw" data-user="mtoutside" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/mtoutside/pen/NWeYVRw">
  &lt;dialog&gt; demo with CSSAnimation</a> by mtoutside (<a href="https://codepen.io/mtoutside">@mtoutside</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

<details><summary>cssはこちら</summary>

```scss
.btn {
  cursor: pointer;
}

body.is-modal {
  overflow: hidden;
}

dialog,
::backdrop {
  opacity: 0;
  transition: opacity 1s, display 1s allow-discrete, overlay 1s allow-discrete;
}

::backdrop {
  background: rgba(94, 94, 94, 0.5);
  backdrop-filter: blur(4px);
}

dialog[open],
dialog[open]::backdrop {
  opacity: 1;
}

@starting-style {

  dialog[open],
  dialog[open]::backdrop {
    opacity: 0;
  }
}

.dialog {
  display: block;
  position: fixed;
  top: 0;
  bottom: 0;
  left: 0;
  transform-origin: center;
  padding: 0;
  min-width: 600px;
  margin: auto;
}

.dialog__inner {
  padding: 16px;
}

.dialog__text {
  margin: 500px 0;
}
```

</details>

`@starting-style`はトランジションでの変化前のスタイルを適用させることができます。`dialog`のオープン時と`dialog`の背景の変化前に`opacity: 0;`を設定しておきます。

https://caniuse.com/mdn-css_at-rules_starting-style

`popover`や`dialog`など最上位レイヤーをフェードアウトさせるにはトランジションのリストに`overlay`プロパティを追加します。

https://caniuse.com/mdn-css_properties_overlay

`transition-behavior`プロパティに`allow-discrete`を設定します。

https://caniuse.com/mdn-css_properties_transition-behavior

アニメーションを開始し、50%で初期値から最終値へと反転します。また、`display: none;`や`content-visibility: hidden;`が初期値や最終値に設定されている場合、トランジション中は`visible`になります。

なので、`dialog`や`::backdrop`には
```css
dialog,
::backdrop {
  opacity: 0;
  transition: opacity 1s, display 1s allow-discrete, overlay 1s allow-discrete;
}

dialog[open],
dialog[open]::backdrop {
  opacity: 1;
}

@starting-style {

  dialog[open],
  dialog[open]::backdrop {
    opacity: 0;
  }
}

```
を設定します。

## まとめ
ざっくりとですが`dialog`を使用したモーダル実装の解説でした。CSSだけでトランジションアニメーションを設定できるのはかなり楽だなと思いました。ですが、2023年9月時点だとまだchromeぐらいでしか使えないのが難点です。safariやFirefoxでの実装が待ち遠しいですね。

**参考**

https://coliss.com/articles/build-websites/operation/css/adds-6-new-css-features-in-chrome-117.html

https://zenn.dev/kii/articles/modal-with-dialog-tag-for-multi-platform

http://geckotang.github.io/presentation-html-dialog/#/