ダイアログ コンポーネントを作成する

<dialog> 要素を使用して、色適応性、応答性、アクセスしやすいミニモーダルとメガモーダルを作成する方法の基本的な概要。

この投稿では、<dialog> 要素を使用して、色適応性、応答性が高く、アクセスしやすいミニモーダルとメガモーダルを作成する方法について、私の考えを共有します。デモソースを表示してください。

ライトモードとダークモードでのメガおよびミニ ダイアログのデモ。

動画で視聴したい場合は、この投稿の YouTube バージョンをご利用ください。

概要

<dialog> 要素は、ページ内コンテキスト情報やアクションに適しています。複数ページにわたるアクションではなく、同一ページ アクションがユーザー エクスペリエンスにメリットをもたらす場合について検討します。たとえば、フォームが小さい場合や、確認やキャンセルのみの操作がユーザーに必要な場合などです。

最近、<dialog> 要素はブラウザで安定版になりました。

対応ブラウザ

  • 37
  • 79
  • 98
  • 15.4

ソース

要素に欠けている点がいくつかあるため、この GUI チャレンジでは、デベロッパー エクスペリエンスとして期待する項目(追加のイベント、ライトを閉じる、カスタム アニメーション、ミニタイプとメガタイプ)を追加します。

マークアップ

<dialog> 要素の基本は控えめです。要素は自動的に非表示になり、コンテンツをオーバーレイするスタイルが組み込まれています。

<dialog>
  …
</dialog>

このベースラインを改善できます。

従来、ダイアログ要素はモーダルと多く共有しており、多くの場合、名前は交換可能です。ここでは、小さなダイアログ ポップアップ(ミニ)とフルページ ダイアログ(メガ)の両方でダイアログ要素を自由に使用することにしました。私はメガとミニと名付けましたが、どちらのダイアログも若干異なるユースケースに合わせて調整されています。タイプを指定できるように modal-mode 属性を追加しました。

<dialog id="MegaDialog" modal-mode="mega"></dialog>
<dialog id="MiniDialog" modal-mode="mini"></dialog>

ライトモードとダークモードのミニダイアログとメガ ダイアログの両方のスクリーンショット。

常にとは限りませんが、一般的に、ダイアログ要素はインタラクション情報の収集に使用されます。ダイアログ要素内のフォームは連携するように設計されています。ダイアログのコンテンツをフォーム要素でラップして、ユーザーが入力したデータに JavaScript がアクセスできるようにすることをおすすめします。さらに、method="dialog" を使用するフォーム内のボタンでは、JavaScript を使用せずにダイアログを閉じて、データを渡すことができます。

<dialog id="MegaDialog" modal-mode="mega">
  <form method="dialog">
    …
    <button value="cancel">Cancel</button>
    <button value="confirm">Confirm</button>
  </form>
</dialog>

メガ ダイアログ

メガ ダイアログには、フォーム内に <header><article><footer> の 3 つの要素があります。これらはセマンティック コンテナとして機能し、ダイアログの表示のスタイル ターゲットとして機能します。ヘッダーにはモーダルのタイトルと、閉じるボタンがあります。この記事では、フォームの入力と情報について説明します。フッターには操作ボタンの <menu> が入ります。

<dialog id="MegaDialog" modal-mode="mega">
  <form method="dialog">
    <header>
      <h3>Dialog title</h3>
      <button onclick="this.closest('dialog').close('close')"></button>
    </header>
    <article>...</article>
    <footer>
      <menu>
        <button autofocus type="reset" onclick="this.closest('dialog').close('cancel')">Cancel</button>
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

最初のメニューボタンには、autofocusonclick インライン イベント ハンドラがあります。autofocus 属性はダイアログが開いたときにフォーカスされますが、これは確認ボタンではなくキャンセル ボタンに配置することをおすすめします。これにより、確認が偶発的ではなく意図的なものになります。

ミニダイアログ

ミニダイアログはメガ ダイアログとよく似ており、<header> 要素が欠落しているだけです。サイズを小さくしてインライン化できます。

<dialog id="MiniDialog" modal-mode="mini">
  <form method="dialog">
    <article>
      <p>Are you sure you want to remove this user?</p>
    </article>
    <footer>
      <menu>
        <button autofocus type="reset" onclick="this.closest('dialog').close('cancel')">Cancel</button>
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

ダイアログ要素は、データとユーザー操作を収集できる完全なビューポート要素のための強固な基盤を提供します。これらの基本原則により、サイトやアプリで非常に興味深く効果的なインタラクションを行うことができます。

ユーザー補助

ダイアログ要素には優れたユーザー補助機能が組み込まれています。これらの機能は、いつものように追加されていますが、多くはすでに存在します。

フォーカスを復元しています

sidenav コンポーネントを作成するで手動で行ったように、何かを適切に開閉する際に、関連する開閉ボタンに焦点を当てることが重要です。そのサイド ナビゲーションが開くと、閉じるボタンにフォーカスが移動します。閉じるボタンを押すと、開いたボタンにフォーカスが復元されます。

ダイアログ要素では、これは組み込みのデフォルトの動作です。

残念ながら、ダイアログの出入りをアニメーション化すると、この機能は失われます。JavaScript セクションで、この機能を復元します。

トラップの焦点

ダイアログ要素は、ドキュメント上の inert を管理します。inert より前は、JavaScript はフォーカスが要素から離れるのを監視するために使用されていました。フォーカスはその時点でインターセプトして元に戻していました。

対応ブラウザ

  • 102
  • 102
  • 112
  • 15.5

ソース

inert の後は、フォーカス ターゲットではなくなるか、マウスを使って操作しない限り、ドキュメントの任意の部分を「固定」できます。フォーカスをトラップするのではなく、ドキュメントの唯一のインタラクティブな部分にフォーカスが移動されます。

要素を開いてオートフォーカスする

デフォルトでは、ダイアログ要素は、ダイアログ マークアップ内の最初のフォーカス可能な要素にフォーカスを割り当てます。これがユーザーのデフォルト要素として最適でない場合は、autofocus 属性を使用します。前述のように、これを確定ボタンではなくキャンセル ボタンに配置することをおすすめします。これにより、確認が意図的であり、偶発的ではないことが保証されます。

Esc キーで閉じる

この要素を簡単に閉じることが重要です。ダイアログの要素はエスケープキーを自動的に処理するため、オーケストレーションの負担から解放されます。

スタイル

ダイアログ要素のスタイル設定には簡単な方法とハードパスがあります。簡単な方法では、ダイアログの表示プロパティを変更せず、その制限を操作します。さらに、ダイアログの開閉や display プロパティなどを引き継ぐためのカスタム アニメーションを提供するというハードな方法もあります。

開いた小道具を使用したスタイル設定

アダプティブ カラーと全体的なデザインの一貫性を高めるために、私は CSS 変数ライブラリの Open Props を導入しました。無料で提供される変数に加えて、正規化ファイルといくつかのボタンもインポートします。Open Props ではどちらもオプションのインポートとして提供されます。これらのインポートにより、ダイアログとデモのカスタマイズに集中でき、サポートやデモのためのスタイルをあまり必要とせずに済みます。

<dialog> 要素のスタイル設定

表示プロパティを所有する

ダイアログ要素のデフォルトの表示と非表示の動作では、display プロパティが block から none に切り替わります。そのため 内外にアニメーション化することは できませんインとアウトの両方をアニメーション化し、最初のステップとして独自の display プロパティを設定します。

dialog {
  display: grid;
}

上記の CSS スニペットに示すように、ディスプレイ プロパティの値を変更して所有するには、適切なユーザー エクスペリエンスを実現するために、多くのスタイルを管理する必要があります。まず、ダイアログのデフォルトの状態が閉じます。この状態を視覚的に表現することで、ダイアログが次のスタイルによるインタラクションを受け取らないようにすることができます。

dialog:not([open]) {
  pointer-events: none;
  opacity: 0;
}

ダイアログが表示されなくなって、開いていないときは操作できなくなりました。後で、ダイアログの inert 属性を管理する JavaScript を追加して、キーボードやスクリーン リーダーのユーザーも非表示のダイアログにアクセスできないようにします。

ダイアログにアダプティブ カラーテーマを設定する

ライトモードとダークモードを示すメガ ダイアログで、サーフェスの色が示されています。

color-scheme では、ライトとダークのシステム設定に合わせてブラウザが提供するアダプティブ カラーテーマにドキュメントがオプトインされますが、それ以外はダイアログ要素をカスタマイズしたいと思っています。Open Props には、color-scheme を使用する場合と同様に、ライトとダークのシステム設定に自動的に適応するサーフェス カラーがいくつか用意されています。これらは、デザインにレイヤを作成するのに最適です。レイヤ サーフェスの外観を視覚的にサポートするために色を使用するのが気に入っています。背景色は var(--surface-1) です。このレイヤの上に配置するには、var(--surface-2) を使用します。

dialog {
  …
  background: var(--surface-2);
  color: var(--text-1);
}

@media (prefers-color-scheme: dark) {
  dialog {
    border-block-start: var(--border-size-1) solid var(--surface-3);
  }
}

ヘッダーやフッターなどの子要素については、後ほどアダプティブ カラーを追加します。私は、ダイアログ要素ではこれらを追加できないと考えていますが、説得力のある優れたデザインのダイアログ デザインを作成するには、非常に重要なことです。

レスポンシブ ダイアログのサイズ設定

ダイアログでは、デフォルトでサイズをそのコンテンツに委任しますが、通常は問題ありません。ここでの目標は、max-inline-size を読みやすいサイズ(--size-content-3 = 60ch)またはビューポートの幅の 90% に制限することです。これにより、ダイアログがモバイル デバイスでは端から端まで表示されることがなくなり、パソコン画面では幅が大きくなりすぎて読みにくいことがなくなります。次に、ダイアログがページの高さを超えないように max-block-size を追加します。また、縦長のダイアログ要素の場合、ダイアログのスクロール可能領域がどこにあるかを指定する必要があります。

dialog {
  …
  max-inline-size: min(90vw, var(--size-content-3));
  max-block-size: min(80vh, 100%);
  max-block-size: min(80dvb, 100%);
  overflow: hidden;
}

max-block-size が 2 回あることに注目してください。1 つ目の方法は、物理ビューポート ユニットである 80vh を使用します。本当に望ましいのは、国際的なユーザーのためにダイアログを相対フロー内に維持することです。そうするために、2 番目の宣言で、論理的で新しい、部分的にサポートされている dvb ユニットを使用して、より安定した結果が得られるようにします。

メガ ダイアログの配置

ダイアログ要素の配置を容易にするために、全画面の背景とダイアログ コンテナという 2 つの部分に分割することをおすすめします。背景はすべてを覆うようにして、このダイアログが前面にあり、背後のコンテンツにアクセスできないように、シェード効果を提供する必要があります。ダイアログ コンテナは、この背景の中央に自由に自身を合わせ、コンテンツに必要なあらゆる形状にできます。

次のスタイルは、ダイアログ要素をウィンドウに固定して各隅に引き伸ばし、margin: auto を使用してコンテンツを中央に配置します。

dialog {
  …
  margin: auto;
  padding: 0;
  position: fixed;
  inset: 0;
  z-index: var(--layer-important);
}
モバイル メガ ダイアログのスタイル

小さいビューポートでは、この全画面のメガモーダルのスタイルが若干異なります。下余白を 0 に設定して、ダイアログのコンテンツがビューポートの下部に表示されるようにします。スタイルをいくつか調整することで、ダイアログをユーザーの親指に近いアクション シートに変えることができます。

@media (max-width: 768px) {
  dialog[modal-mode="mega"] {
    margin-block-end: 0;
    border-end-end-radius: 0;
    border-end-start-radius: 0;
  }
}

開いたときに、パソコンとモバイルの両方のメガ ダイアログに余白の余白が重なっている DevTools のスクリーンショット。

ミニ ダイアログの配置

デスクトップ パソコンなどの大きなビューポートを使用する場合、ミニダイアログを呼び出した要素の上に配置することにしました。そのためには、JavaScript が必要です。使用している手法はこちらで確認できますが、この記事では説明できないと思います。JavaScript を使用しない場合、ミニ ダイアログはメガ ダイアログと同様に画面の中央に表示されます。

目立たせる

最後に、ダイアログに彩りを加え、ページの上端にある柔らかなサーフェスに見えるようにします。柔らかさを実現するには、ダイアログの角を丸くします。深度を出すには、Open Props の慎重に作成された次のシャドウ プロップを使用します。

dialog {
  …
  border-radius: var(--radius-3);
  box-shadow: var(--shadow-6);
}

背景疑似要素のカスタマイズ

背景の処理は軽くして、メガ ダイアログには backdrop-filter でぼかし効果を追加するだけでした。

対応ブラウザ

  • 76
  • 79
  • 103
  • 9

ソース

dialog[modal-mode="mega"]::backdrop {
  backdrop-filter: blur(25px);
}

また、将来的にブラウザが背景要素を移行できるように、backdrop-filter に遷移を設定することにしました。

dialog::backdrop {
  transition: backdrop-filter .5s ease;
}

カラフルなアバターの背景をぼかしたメガダイアログのスクリーンショット。

その他のスタイル設定

このセクションを「エクストラ」と呼ぶのは、一般的なダイアログ要素よりもダイアログ要素のデモに関係しているためです。

スクロールの包含

ダイアログが表示されても、ユーザーは背後のページをスクロールできますが、これは望ましくありません。

通常は overscroll-behavior を使うのが普通の解決策ですが、仕様によると、これはスクロール ポート、つまりスクローラーではないため、ダイアログには影響しません。したがって、防止するものはありません。JavaScript を使用して、このガイドからの「closed」や「opened」などの新しいイベントを監視し、ドキュメントの overflow: hidden を切り替えるか、すべてのブラウザで :has() が安定するまで待つことができます。

対応ブラウザ

  • 105
  • 105
  • 121
  • 15.4

ソース

html:has(dialog[open][modal-mode="mega"]) {
  overflow: hidden;
}

これで、メガ ダイアログが開いているとき、html ドキュメントに overflow: hidden が表示されるようになりました。

<form> レイアウト

ユーザーからインタラクション情報を収集するうえで非常に重要な要素であるだけでなく、ヘッダー、フッター、記事の要素のレイアウトにも使用しています。このレイアウトでは、記事の子をスクロール可能な領域として明確にします。これは grid-template-rows で実現します。記事要素に 1fr が指定され、フォーム自体の最大高さがダイアログ要素と同じになります。固定の高さと固定の行サイズを設定することで、記事要素がオーバーフローしたときに制限してスクロールできるようになります。

dialog > form {
  display: grid;
  grid-template-rows: auto 1fr auto;
  align-items: start;
  max-block-size: 80vh;
  max-block-size: 80dvb;
}

グリッド レイアウト情報を行にオーバーレイしている DevTools のスクリーンショット。

ダイアログ <header> のスタイルを設定する

この要素の役割は、ダイアログ コンテンツのタイトルと、見つけやすい閉じるボタンを提供することです。また、ダイアログの記事コンテンツの背後に見えるように、サーフェスの色も指定されています。こうした要件により、Flexbox コンテナ、端に間隔を置いて垂直方向に並べられたアイテム、タイトル ボタンと閉じるボタンに余裕を持たせるパディングとギャップが発生します。

dialog > form > header {
  display: flex;
  gap: var(--size-3);
  justify-content: space-between;
  align-items: flex-start;
  background: var(--surface-2);
  padding-block: var(--size-3);
  padding-inline: var(--size-5);
}

@media (prefers-color-scheme: dark) {
  dialog > form > header {
    background: var(--surface-1);
  }
}

ダイアログのヘッダーに Flexbox のレイアウト情報を重ねて表示している Chrome DevTools のスクリーンショット。

ヘッダーの閉じるボタンのスタイルを設定する

このデモでは [Open Props] ボタンを使用しているため、以下のように、閉じるボタンは丸いアイコン中心のボタンにカスタマイズされています。

dialog > form > header > button {
  border-radius: var(--radius-round);
  padding: .75ch;
  aspect-ratio: 1;
  flex-shrink: 0;
  place-items: center;
  stroke: currentColor;
  stroke-width: 3px;
}

ヘッダーの閉じるボタンのサイズとパディングの情報をオーバーレイしている Chrome DevTools のスクリーンショット。

ダイアログ <article> のスタイルを設定する

このダイアログでは、記事の要素に特別な役割があります。縦長または長いダイアログの場合、この領域はスクロールされるスペースです。

これを実現するために、親フォーム要素は自身にいくつかの最大値を設けています。これにより、この記事の要素が高すぎる場合に到達する制約が決まります。overflow-y: auto を設定すると、スクロールバーが必要なときにのみ表示され、overscroll-behavior: contain によってスクロールバーが含まれ、残りはカスタム プレゼンテーション スタイルになります。

dialog > form > article {
  overflow-y: auto; 
  max-block-size: 100%; /* safari */
  overscroll-behavior-y: contain;
  display: grid;
  justify-items: flex-start;
  gap: var(--size-3);
  box-shadow: var(--shadow-2);
  z-index: var(--layer-1);
  padding-inline: var(--size-5);
  padding-block: var(--size-3);
}

@media (prefers-color-scheme: light) {
  dialog > form > article {
    background: var(--surface-1);
  }
}

フッターの役割は、操作ボタンのメニューを含めることです。Flexbox は、コンテンツをフッターのインライン軸の終端に合わせて配置し、その後にボタンに一定のスペースを与えるために使用されます。

dialog > form > footer {
  background: var(--surface-2);
  display: flex;
  flex-wrap: wrap;
  gap: var(--size-3);
  justify-content: space-between;
  align-items: flex-start;
  padding-inline: var(--size-5);
  padding-block: var(--size-3);
}

@media (prefers-color-scheme: dark) {
  dialog > form > footer {
    background: var(--surface-1);
  }
}

Flexbox のレイアウト情報がフッター要素に重なっている Chrome DevTools のスクリーンショット。

menu 要素は、ダイアログのアクション ボタンを格納するために使用されます。gap でラッピング フレックスボックス レイアウトを使用して、ボタン間のスペースを確保します。メニュー要素には、<ul> などのパディングがあります。スタイルは不要なため、削除します。

dialog > form > footer > menu {
  display: flex;
  flex-wrap: wrap;
  gap: var(--size-3);
  padding-inline-start: 0;
}

dialog > form > footer > menu:only-child {
  margin-inline-start: auto;
}

Flexbox の情報がフッターのメニュー要素に重なっている Chrome DevTools のスクリーンショット。

アニメーション

ダイアログ要素は多くの場合、ウィンドウに出入りするため、アニメーション化されます。この開始と終了をダイアログにサポート モーションを提供することで、ユーザーがフローになじめるようになります。

通常、ダイアログの要素はアニメーションインのみ可能で、アウトできません。これは、ブラウザが要素の display プロパティを切り替えるためです。これまで、ガイドは表示をグリッドに設定していましたが、なしには設定されませんでした。これにより、アニメーションイン/アウトができるようになります。

Open Props には多数のキーフレーム アニメーションが用意されているため、オーケストレーションがわかりやすくなります。私がとったアニメーションの目標と階層的なアプローチです。

  1. 縮小モーションはデフォルトの遷移で、単純な不透明度のフェードインとフェードアウトです。
  2. モーションに問題がなければ、スライドとスケールのアニメーションが追加されます。
  3. メガ ダイアログのレスポンシブ モバイル レイアウトが、スライドアウトするように調整されています。

安全で有意義なデフォルト移行

Open Props にはフェードインとフェードアウト用のキーフレームが用意されていますが、私はこの階層的な遷移アプローチをデフォルトとして使用し、キーフレーム アニメーションをアップグレードすることをおすすめします。先ほど、ダイアログの表示スタイルを不透明度に設定し、[open] 属性に応じて 1 または 0 をオーケストレートしました。0% から 100% の間で移行するには、どのくらい長く、どのようなイージングを使用するかをブラウザに伝えます。

dialog {
  transition: opacity .5s var(--ease-3);
}

遷移にモーションを追加する

ユーザーが動きに問題がなければ、メガ ダイアログとミニ ダイアログの両方が入り口としてスライドアップし、出口としてスケールアウトします。これは、prefers-reduced-motion メディアクエリといくつかの Open Prop で実現できます。

@media (prefers-reduced-motion: no-preference) {
  dialog {
    animation: var(--animation-scale-down) forwards;
    animation-timing-function: var(--ease-squish-3);
  }

  dialog[open] {
    animation: var(--animation-slide-in-up) forwards;
  }
}

終了アニメーションをモバイル用に調整する

スタイルのセクションで述べたように、メガ ダイアログのスタイルは、モバイル デバイス向けに、小さな紙片が画面の下から上にスライドしてそのままでも付いているかのように、アクション シートのようになるように調整されています。スケールアウト終了のアニメーションは、この新しいデザインにうまく適合していないため、いくつかのメディアクエリといくつかの Open Prop で適応させることができます。

@media (prefers-reduced-motion: no-preference) and @media (max-width: 768px) {
  dialog[modal-mode="mega"] {
    animation: var(--animation-slide-out-down) forwards;
    animation-timing-function: var(--ease-squish-2);
  }
}

JavaScript

JavaScript では以下のような多くの要素を追加できます。

// dialog.js
export default async function (dialog) {
  // add light dismiss
  // add closing and closed events
  // add opening and opened events
  // add removed event
  // removing loading attribute
}

これらの追加は、軽い閉じる操作(ダイアログの背景をクリック)、アニメーション、およびフォームデータの取得のタイミングを改善するためのその他のイベントを必要とすることから始まります。

ライト拒否を追加しています

このタスクは簡単で、アニメーション化されていないダイアログ要素に追加するのに最適です。インタラクションは、ダイアログ要素のクリックを監視し、イベント バブリングを利用して何がクリックされたかを評価することで実現されます。最上位の要素である場合にのみ、close() が実行されます。

export default async function (dialog) {
  dialog.addEventListener('click', lightDismiss)
}

const lightDismiss = ({target:dialog}) => {
  if (dialog.nodeName === 'DIALOG')
    dialog.close('dismiss')
}

dialog.close('dismiss') に注目してください。イベントが呼び出され、文字列が提供されます。 この文字列を他の JavaScript で取得して、ダイアログがどのように閉じられたかに関する分析情報を取得できます。また、さまざまなボタンから関数を呼び出すたびに、終了文字列を指定して、ユーザー操作に関するコンテキストをアプリケーションに提供します。

終了イベントと終了イベントの追加

ダイアログ要素には終了イベントがあります。ダイアログの close() 関数が呼び出されるとすぐにイベントが発行されます。この要素をアニメーション化するため、データの取得やダイアログ フォームのリセットのために、アニメーションの前後にイベントを用意しておくと便利です。ここでは、閉じたダイアログへの inert 属性の追加を管理します。デモでは、ユーザーが新しい画像を送信した場合のアバターリストの変更に使用します。

そのためには、closingclosed という 2 つの新しいイベントを作成します。次に、ダイアログで組み込みの終了イベントをリッスンします。ここからダイアログを inert に設定し、closing イベントをディスパッチします。次のタスクは、ダイアログでアニメーションと遷移の実行が完了するまで待ってから、closed イベントをディスパッチすることです。

const dialogClosingEvent = new Event('closing')
const dialogClosedEvent  = new Event('closed')

export default async function (dialog) {
  …
  dialog.addEventListener('close', dialogClose)
}

const dialogClose = async ({target:dialog}) => {
  dialog.setAttribute('inert', '')
  dialog.dispatchEvent(dialogClosingEvent)

  await animationsComplete(dialog)

  dialog.dispatchEvent(dialogClosedEvent)
}

const animationsComplete = element =>
  Promise.allSettled(
    element.getAnimations().map(animation => 
      animation.finished))

animationsComplete 関数は、トースト コンポーネントの作成でも使用されているもので、アニメーションと遷移の Promise の完了に基づいて Promise を返します。dialogClose非同期関数であるのはこのためです。そうすると、返された Promise を await して、終了イベントに自信を持って移動できます。

オープン イベントとオープン イベントの追加

組み込みのダイアログ要素では、閉じる場合とは異なりオープンイベントが提供されないため、これらのイベントは簡単に追加できません。MutationObserver を使用して、ダイアログの属性の変化に関する分析情報を提供します。このオブザーバーで、open 属性の変更を監視し、それに応じてカスタム イベントを管理します。

終了イベントと終了イベントを開始したときと同様に、openingopened という 2 つの新しいイベントを作成します。前にダイアログのクローズ イベントをリッスンしていましたが、今回は作成されたミューテーション オブザーバーを使用してダイアログの属性を監視します。

…
const dialogOpeningEvent = new Event('opening')
const dialogOpenedEvent  = new Event('opened')

export default async function (dialog) {
  …
  dialogAttrObserver.observe(dialog, { 
    attributes: true,
  })
}

const dialogAttrObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(async mutation => {
    if (mutation.attributeName === 'open') {
      const dialog = mutation.target

      const isOpen = dialog.hasAttribute('open')
      if (!isOpen) return

      dialog.removeAttribute('inert')

      // set focus
      const focusTarget = dialog.querySelector('[autofocus]')
      focusTarget
        ? focusTarget.focus()
        : dialog.querySelector('button').focus()

      dialog.dispatchEvent(dialogOpeningEvent)
      await animationsComplete(dialog)
      dialog.dispatchEvent(dialogOpenedEvent)
    }
  })
})

ダイアログの属性が変更されると、ミューテーション オブザーバーのコールバック関数が呼び出され、変更のリストが配列として提供されます。属性の変更を反復処理し、attributeName が開いていることを確認します。次に、要素に属性があるかどうかを確認します。この属性は、ダイアログが開いているかどうかを示します。すでに開いている場合は inert 属性を削除し、autofocus をリクエストしている要素か、ダイアログで最初に見つかった button 要素のいずれかにフォーカスを設定します。最後に、終了イベントや終了イベントと同様に、開始イベントをすぐにディスパッチし、アニメーションが完了するまで待ってから、開始イベントをディスパッチします。

削除したイベントの追加

シングルページ アプリケーションでは、ルートやその他のアプリケーションのニーズや状態に基づいてダイアログが追加または削除されることがよくあります。ダイアログが削除されたときにイベントやデータをクリーンアップすると便利です。

これは、別のミューテーション オブザーバーで実現できます。今回は、dialog 要素の属性を監視するのではなく、body 要素の子を監視し、Dialog 要素が削除されないか確認します。

…
const dialogRemovedEvent = new Event('removed')

export default async function (dialog) {
  …
  dialogDeleteObserver.observe(document.body, {
    attributes: false,
    subtree: false,
    childList: true,
  })
}

const dialogDeleteObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(mutation => {
    mutation.removedNodes.forEach(removedNode => {
      if (removedNode.nodeName === 'DIALOG') {
        removedNode.removeEventListener('click', lightDismiss)
        removedNode.removeEventListener('close', dialogClose)
        removedNode.dispatchEvent(dialogRemovedEvent)
      }
    })
  })
})

ミューテーション オブザーバー コールバックは、ドキュメントの本文に子が追加または削除されるたびに呼び出されます。監視対象のミューテーションは、ダイアログの nodeName がある removedNodes に対するものです。ダイアログが削除された場合は、メモリを解放するためにクリック イベントと閉じるイベントが削除され、カスタム削除イベントがディスパッチされます。

読み込み属性の削除

ページに追加されたときやページの読み込み時にダイアログ アニメーションが終了アニメーションを再生しないように、ダイアログに読み込み属性が追加されました。次のスクリプトは、ダイアログ アニメーションの実行が完了するまで待ってから、属性を削除します。これで、ダイアログを自由にアニメーション化でき、気が散るアニメーションは実質的に非表示になりました。

export default async function (dialog) {
  …
  await animationsComplete(dialog)
  dialog.removeAttribute('loading')
}

詳しくは、ページの読み込み時にキーフレーム アニメーションが妨げられることの問題をご覧ください。

すべて一緒に

各セクションを個別に説明したので、以下は dialog.js 全体です。

// custom events to be added to <dialog>
const dialogClosingEvent = new Event('closing')
const dialogClosedEvent  = new Event('closed')
const dialogOpeningEvent = new Event('opening')
const dialogOpenedEvent  = new Event('opened')
const dialogRemovedEvent = new Event('removed')

// track opening
const dialogAttrObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(async mutation => {
    if (mutation.attributeName === 'open') {
      const dialog = mutation.target

      const isOpen = dialog.hasAttribute('open')
      if (!isOpen) return

      dialog.removeAttribute('inert')

      // set focus
      const focusTarget = dialog.querySelector('[autofocus]')
      focusTarget
        ? focusTarget.focus()
        : dialog.querySelector('button').focus()

      dialog.dispatchEvent(dialogOpeningEvent)
      await animationsComplete(dialog)
      dialog.dispatchEvent(dialogOpenedEvent)
    }
  })
})

// track deletion
const dialogDeleteObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(mutation => {
    mutation.removedNodes.forEach(removedNode => {
      if (removedNode.nodeName === 'DIALOG') {
        removedNode.removeEventListener('click', lightDismiss)
        removedNode.removeEventListener('close', dialogClose)
        removedNode.dispatchEvent(dialogRemovedEvent)
      }
    })
  })
})

// wait for all dialog animations to complete their promises
const animationsComplete = element =>
  Promise.allSettled(
    element.getAnimations().map(animation => 
      animation.finished))

// click outside the dialog handler
const lightDismiss = ({target:dialog}) => {
  if (dialog.nodeName === 'DIALOG')
    dialog.close('dismiss')
}

const dialogClose = async ({target:dialog}) => {
  dialog.setAttribute('inert', '')
  dialog.dispatchEvent(dialogClosingEvent)

  await animationsComplete(dialog)

  dialog.dispatchEvent(dialogClosedEvent)
}

// page load dialogs setup
export default async function (dialog) {
  dialog.addEventListener('click', lightDismiss)
  dialog.addEventListener('close', dialogClose)

  dialogAttrObserver.observe(dialog, { 
    attributes: true,
  })

  dialogDeleteObserver.observe(document.body, {
    attributes: false,
    subtree: false,
    childList: true,
  })

  // remove loading attribute
  // prevent page load @keyframes playing
  await animationsComplete(dialog)
  dialog.removeAttribute('loading')
}

dialog.js モジュールの使用

モジュールからエクスポートされた関数は、以下の新しいイベントと機能を追加するダイアログ要素が呼び出され、渡されることが想定されています。

import GuiDialog from './dialog.js'

const MegaDialog = document.querySelector('#MegaDialog')
const MiniDialog = document.querySelector('#MiniDialog')

GuiDialog(MegaDialog)
GuiDialog(MiniDialog)

2 つのダイアログがアップグレードされ、簡単な閉じる操作、アニメーションの読み込みの修正、操作可能なイベントの追加が行われました。

新しいカスタム イベントをリッスンする

アップグレードされた各ダイアログ要素で、次のような 5 つの新しいイベントをリッスンできるようになりました。

MegaDialog.addEventListener('closing', dialogClosing)
MegaDialog.addEventListener('closed', dialogClosed)

MegaDialog.addEventListener('opening', dialogOpening)
MegaDialog.addEventListener('opened', dialogOpened)

MegaDialog.addEventListener('removed', dialogRemoved)

これらのイベントの処理例を 2 つ紹介します。

const dialogOpening = ({target:dialog}) => {
  console.log('Dialog opening', dialog)
}

const dialogClosed = ({target:dialog}) => {
  console.log('Dialog closed', dialog)
  console.info('Dialog user action:', dialog.returnValue)

  if (dialog.returnValue === 'confirm') {
    // do stuff with the form values
    const dialogFormData = new FormData(dialog.querySelector('form'))
    console.info('Dialog form data', Object.fromEntries(dialogFormData.entries()))

    // then reset the form
    dialog.querySelector('form')?.reset()
  }
}

Dialogflow 要素を使って作成したデモでは、closed イベントとフォームデータを使用して、新しいアバター要素をリストに追加します。このタイミングは、ダイアログが終了アニメーションを完了し、一部のスクリプトが新しいアバターでアニメーション化するという点で優れています。新しいイベントのおかげで、ユーザー エクスペリエンスのオーケストレーションがよりスムーズになります。

dialog.returnValue: これには、ダイアログの close() イベントが呼び出されたときに渡されたクローズ文字列が含まれます。dialogClosed イベントでは、ダイアログが閉じられたか、キャンセルされたか、確認されたかを把握することが重要です。確認された場合、スクリプトはフォームの値を取得して、フォームをリセットします。このリセット機能は、ダイアログが再び表示されたときに空白になり、新しい送信の準備が整う場合に便利です。

おわりに

どのようにやり方をしたかわかったので、どのように感じますか? ‽ 🙂?

アプローチを多様化して、ウェブで構築するすべての方法を学びましょう。

デモを作成し、ツイートしてリンクをツイートしてください。以下のコミュニティ リミックスのセクションに追加いたします。

コミュニティ リミックス

リソース