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

<dialog> 要素を使用して、色適応性、応答性、アクセシビリティに関するミニモーダルとメガモーダルを作成する方法の基本的な概要です。

この投稿では、<dialog> 要素を使用して、色適応性、応答性、アクセシビリティに優れたミニモーダルとメガモーダルを作成する方法について、私の考えを共有したいと思います。デモをお試しください。また、ソースを確認してください。

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

動画をご覧になる場合は、この投稿の YouTube バージョンをご覧ください。

概要

<dialog> 要素は、ページ内のコンテキスト情報やアクションに最適です。フォームが小さい場合や、確認またはキャンセルのみのアクションが必要な場合など、複数ページにわたるアクションではなく、同じページに対するアクションでユーザー エクスペリエンスが向上するケースを考えてみましょう。

最近、<dialog> 要素がどのブラウザでも利用可能になりました。

対応ブラウザ

  • 37
  • 79
  • 98
  • 15.4

ソース

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

マークアップ

<dialog> 要素の基本はシンプルです。要素は自動的に非表示になり、コンテンツに重ねて表示されるスタイルが組み込まれています。

<dialog>
  …
</dialog>

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

従来、ダイアログ要素はモーダルと多くの部分を共有しており、多くの場合、名前は同じです。ここでは、小さなダイアログ ポップアップ(ミニ)とフルページ ダイアログ(メガ)の両方にダイアログ要素を使用することにしました。「mega」と「mini」という名前にしました。どちらのダイアログも、ユースケースごとに若干調整して使用しています。タイプを指定できるように 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>

ダイアログ要素は、データとユーザー操作を収集できるビューポート要素全体の基礎となります。こうした基本的な要素があれば、サイトやアプリで非常に魅力的で効果的なインタラクションを実現できます。

ユーザー補助

ダイアログ要素には優れたユーザー補助機能が組み込まれています。私が通常行う機能の代わりに、多くの機能がすでに搭載されています。

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

サイド ナビゲーション コンポーネントを作成するで手作業で行ったように、何かを適切に開閉することで、関連する開閉ボタンにフォーカスを移すことが重要です。サイド ナビゲーションが開くと、閉じるボタンにフォーカスが合わせられます。閉じるボタンを押すと、開いたボタンにフォーカスが復元されます。

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

残念ながら、ダイアログにアニメーションを付けると、この機能は使用できなくなります。JavaScript セクションで、この機能を復元します。

トラップ フォーカス

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

対応ブラウザ

  • 102
  • 102
  • 112
  • 15.5

ソース

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

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

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

Esc キーによる終了

この中断が発生する可能性がある要素を簡単に閉じることが重要です。幸いなことに、ダイアログ要素がエスケープ キーを処理するので、オーケストレーションの負担から解放されます。

スタイル

ダイアログ要素のスタイル設定には簡単なパスがあり、ハードパスもあります。簡単なパスを実現するには、ダイアログの表示プロパティを変更せず、その制限を操作します。ハードパスに沿って、display プロパティなどを引き継いだダイアログの開閉用のカスタム アニメーションを作成します。

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

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

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

ディスプレイ プロパティを所有している

ダイアログ要素のデフォルトの表示 / 非表示動作では、表示プロパティが 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
  • 17
  • 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 のスクリーンショット。

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

デモでは 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 でラッピング Flexbox レイアウトを使用し、ボタン間のスペースを確保しています。メニュー要素には、<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 Props を使用して実現できます。

@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 Props を使用して調整できます。

@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 文字列も表示されています。

終了イベントと終了イベントを追加する

ダイアログ要素には閉じるイベントがあり、ダイアログの 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 の完了に基づいて 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 要素にフォーカスを設定します。最後に、終了イベントとクローズイベントと同様に、開始イベントをすぐにディスパッチし、アニメーションが終了するまで待ってから、開かれたイベントをディスパッチします。

削除されたイベントを追加する

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

これは、別のミューテーション オブザーバーで実現できます。今回は、ダイアログ要素の属性を監視するのではなく、body 要素の子を監視し、ダイアログ要素が削除されるのを監視します。

…
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()
  }
}

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

注意 dialog.returnValue: これには、ダイアログの close() イベントが呼び出されたときに渡される終了文字列が含まれています。dialogClosed イベントで、ダイアログが閉じられたか、キャンセルされたか、確認されたかを知ることが重要です。確認されると、スクリプトはフォームの値を取得して、フォームをリセットします。リセットすると、ダイアログが再び表示されたときに、ダイアログを空白にして、新たに送信できる状態になるため便利です。

まとめ

私のやり方がわかったところで、どうしたらいいですか? 🙂?

多様なアプローチを活用し、ウェブでアプリをビルドするためのあらゆる方法を学びましょう。

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

コミュニティのリミックス

リソース