LoginSignup
0
0

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

Last updated at Posted at 2023-09-27

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

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

See the Pen <dialog> demo with fadeAnimation and scroll lock by mtoutside (@mtoutside) on CodePen.

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

cssはこちら
@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;
}

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

JavaScript

classを使用しています。

/**
 * モーダルダイアログを管理するためのユーティリティクラスです。
 */
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();
});

モーダルを開く

// モーダルを開く
  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); で一番上に戻します。この設定がないと前回開いたモーダルのスクロール位置のままになってしまいます。

モーダルを閉じる

// モーダルを閉じる
  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を使ってアニメーションの終了イベントを取得し、アニメーションが終了したら非表示クラスを付与して消す方法をとります。

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

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

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

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

モダールの設定は以下のように行います。

// モーダルの設定
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のみでモーダルをアニメーションさせる

デモはこちら。

See the Pen <dialog> demo with CSSAnimation by mtoutside (@mtoutside) on CodePen.

cssはこちら
.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;
}

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

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

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

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

なので、dialog::backdropには

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での実装が待ち遠しいですね。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0