Katashin .info

アクションシートの実装から学ぶ <dialog> 要素を使う時の3つの落とし穴

<dialog> 要素が主要なブラウザすべてに実装され、現実的に使えるようになってきましたが、これをそのまま実際の Web アプリで使うには様々なものが足りません。足りないものを自分で実装していくと、分かりづらい挙動があったり、癖のある実装が必要なことがあります。例えば、<dialog> 要素のデフォルトは中央揃えで、コンテンツに合わせたサイズになりますが、これの位置、サイズ調整やアニメーションをする際に落とし穴があります。

本記事では、以下のアクションシートのようなモーダルを実装する例を通して、<dialog> 要素を使う時の3つの落とし穴を紹介します。

デフォルトのスタイルが分かりづらい #

<dialog> 要素にはデフォルトでいくつかのスタイルが設定されていますが、これまでの HTML 要素のデフォルトスタイルと比べると値が特殊で、初見では挙動が分かりづらいです。showModal で呼び出した時とそうでない時もスタイルが異なり、前者の方がデフォルトスタイルに癖があります。

<dialog> 要素でアクションシートのような UI を実装するには上記のスタイルをすべて解除する必要があります。下側に配置するため top: auto を、幅が画面いっぱいに広がるように max-width: nonewidth: auto を設定し、box-shadow を使いたい場合は overflow: visible を設定します。

/* ダイアログのスタイル */
dialog {
  /* デフォルトが inset: 0; なので top: auto; にして下側に配置する */
  top: auto;

  /* デフォルトで最大幅が設定されていて width: 100% にならないので解除 */
  max-width: none;

  /* デフォルトで fit-content になっていて中身の幅になってしまうので解除 */
  width: auto;

  /*
   * デフォルトが auto なので visible にして box-shadow が表示されるようにする
   * スクロールさせたい場合は子孫要素で overflow: auto などを指定する
   */
  overflow: visible;

  padding: 16px;
  background-color: #fff;
  border: none;
  border-radius: 16px 16px 0 0;
  box-sizing: border-box;
  box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
}

/* 背景のスタイルは ::backdrop 疑似要素で指定 */
dialog::backdrop {
  background-color: rgba(0, 0, 0, 0.3);
  backdrop-filter: blur(2px);
}

このスタイルを設定すると、以下のようなアクションシートになります。「開く」ボタンをクリックすると、アクションシートが表示されます。

表示、非表示のアニメーション実装に癖がある #

例えば、アクションシートには下から出てくるアニメーション、背景にはフェードインで表れるアニメーションを設定したい時、CSS トランジションで以下のように書きたくなりますが、これは意図した動作をしません。その理由は、<dialog> 要素が非表示になる時に display: none が指定され、display プロパティは CSS トランジションの対象ではないためです。

/* ダイアログと背景に CSS トランジションを設定 */
dialog,
dialog::backdrop {
  transition: 0.3s cubic-bezier(0.33, 1, 0.68, 1);
}

/* (動かない例) ダイアログが非表示の時は下に移動 */
dialog:not([open]) {
  translate: 0 100%;
}

/* (動かない例) ダイアログが非表示の時は背景を透明にする */
dialog:not([open])::backdrop {
  opacity: 0;
}

アニメーションさせる1つの方法として、以下のように表示前、非表示後のスタイルをそれぞれ show-fromhide-to というクラスで定義し、JavaScript と組み合わせるというものがあります。

/* ダイアログと背景に CSS トランジションを設定 */
dialog,
dialog::backdrop {
  transition: 0.3s cubic-bezier(0.33, 1, 0.68, 1);
}

/* ダイアログの表示前、非表示後は下に移動 */
.show-from,
.hide-to {
  translate: 0 100%;
}

/* ダイアログの表示前、非表示後は背景を透明にする */
.show-from::backdrop,
.hide-to::backdrop {
  opacity: 0;
}

JavaScript は以下のようにします。開くボタンがクリックされた時は、dialog.showModal を呼び出すのと同時に show-from クラスを <dialog> 要素に追加し、表示前のスタイルを設定します。requestAnimationFrame でそのスタイルが描画されるのを待った後、show-from クラスを削除して、CSS トランジションを開始します。

const dialog = document.querySelector('dialog')

// 開くボタンをクリックされた時
document.querySelector('#open').addEventListener('click', show)

function show() {
  // モーダル表示前にクラスを付与
  dialog.classList.add('show-from')
  dialog.showModal()

  requestAnimationFrame(() => {
    // モーダル表示後にクラスを削除してアニメーションを開始
    dialog.classList.remove('show-from')
  })
}

閉じるボタンがクリックされた時は、hide-to クラスを <dialog> 要素に追加し、非表示アニメーションを実行する CSS トランジションを開始します。アニメーションが終わったことを transitionend イベントで検出し、hide-to クラスを削除した後に、dialog.close を呼び出して <dialog> 要素を非表示にします。

// 閉じるボタンをクリックされた時
document.querySelector('#close').addEventListener('click', close)

function close() {
  // モーダル非表示前にクラスを付与してアニメーションを開始
  dialog.classList.add('hide-to')

  dialog.addEventListener(
    'transitionend',
    () => {
      // アニメーション終了後にクラスを削除し、モーダルを閉じる
      dialog.classList.remove('hide-to')
      dialog.close()
    },
    {
      once: true,
    },
  )
}

<dialog> 要素は ESC キーを押すことでも閉じれるので、keydown イベントも監視します。ESC キーが押された時にブラウザデフォルトの挙動をキャンセルし、アニメーション付きの閉じる処理を呼び出します。

dialog.addEventListener('keydown', keydown)

function keydown(event) {
  // デフォルトの ESC キーで閉じる動作をキャンセルし、アニメーション付きの閉じる処理を呼び出す
  event.preventDefault()
  if (event.key === 'Escape') {
    // 先ほど実装した閉じる処理
    close()
  }
}

以下はこれまでの実装のデモです。

Web 標準を策定する側も、以上のような実装が必要なのは解決すべき課題だと考えているようで、以下の機能が Chrome 116 および 117 で実装されています

これらが使えるようになれば、追加のクラスを定義して JavaScript で色々やらなくても、単純に open 属性の有無でスタイルを変え、CSS トランジションをすれば良くなるでしょう。

::backdrop クリックで閉じる実装がややこしい #

ダイアログによくある動作として、背景をクリックした時にダイアログを閉じるというものがありますが、<dialog> 要素はそれに対応していません。自分で実装するにしても ::backdrop 疑似要素を JavaScript で取得することができないので工夫をする必要があります。

::backdrop 疑似要素がクリックされた時、JavaScript 上は <dialog> 要素がクリックされたとみなされます。これを利用して、ダイアログの見た目のスタイルをすべて <dialog> 要素の子要素 .inner に設定して、<dialog> 要素自体は不可視にすることで、背景がクリックされたことを検知します。

<button id="open" type="button">開く</button>

<dialog>
  <!-- inner を増やして見た目のスタイルをすべてこれに設定 -->
  <div class="inner">
    <p>アクションシートのようなモーダル</p>
    <button id="close" type="button">閉じる</button>
  </div>
</dialog>
dialog {
  /* 諸々のデフォルトスタイルを解除 */
  overflow: visible;
  top: auto;
  max-width: none;
  width: auto;
  padding: 0;
  border: none;
  background: none;
}

.inner {
  /* 見た目に関するスタイルはこちらに移す */
  padding: 16px;
  background-color: #fff;
  border-radius: 16px 16px 0 0;
  box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
}

dialog::backdrop {
  background-color: rgba(0, 0, 0, 0.3);
  backdrop-filter: blur(2px);
}

JavaScript では event.target<dialog> 要素であるかをチェックすることで、背景をクリックしたかがわかります。

dialog.addEventListener('click', (event) => {
  // 背景がクリックされた場合は閉じる。
  // ダイアログの見た目のスタイルは .inner に設定しているので、
  // コンテンツ部分がクリックされた場合、target は必ず .inner かその子孫要素になる。
  // したがって、target === dialog の時は背景がクリックされたとみなせる。
  if (event.target === dialog) {
    close()
  }
})

これを実際に動かすと以下のようになります。

結論 #

<dialog> 要素を使うことでブラウザーがアクセシビリティを考慮した挙動をしたり、z-index や DOM 構造を気にしなくても良いという数々の利点がありますが、実際のアプリで使うようなクオリティのダイアログを実装しようとすると、落とし穴がある点に気をつける必要があります。まだまだ Web 標準が持つ機能は増えていき、こういった落とし穴も解消されていくかもしれません。