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

<dialog> 要素を使用して、色に適応し、レスポンシブで、ユーザー補助に対応したミニモーダルとメガモーダルを構築する方法の基本的な概要。

この記事では、<dialog> 要素を使用して、色に適応し、レスポンシブで、ユーザー補助に対応したミニモーダルとメガモーダルを構築する方法について説明します。デモを試すソースを表示する

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

動画で確認したい場合は、YouTube 版の投稿をご覧ください。

概要

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

<dialog> 要素は最近、すべてのブラウザで安定して動作するようになりました。

対応ブラウザ

  • Chrome: 37.
  • Edge: 79.
  • Firefox: 98.
  • Safari: 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>

ダイアログ要素は、データとユーザー操作を収集できるフルビューポート要素の強固な基盤となります。これらの基本要素を活用することで、サイトやアプリで非常に興味深く強力なインタラクションを実現できます。

ユーザー補助

ダイアログ要素には、優れたユーザー補助機能が組み込まれています。通常は追加する機能ですが、多くの機能はすでに用意されています。

フォーカスの復元

サイドナビ コンポーネントの作成で手動で行ったように、開閉を適切に行うことで、関連する開閉ボタンにフォーカスを合わせることが重要です。サイドナビを開くと、閉じるボタンにフォーカスが移動します。閉じるボタンを押すと、開いたボタンにフォーカスが戻ります。

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

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

フォーカスのトラップ

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

対応ブラウザ

  • Chrome: 102。
  • Edge: 102.
  • Firefox: 112。
  • Safari: 15.5。

ソース

inert の後、ドキュメントの任意の部分を「凍結」して、フォーカス ターゲットやマウス操作の対象にならないようにすることができます。フォーカスをトラップするのではなく、ドキュメント内の唯一のインタラクティブな部分にフォーカスを誘導します。

要素を開いて自動フォーカスする

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

Esc キーで閉じる

中断の原因となる可能性のある要素は簡単に閉じられるようにすることが重要です。幸い、ダイアログ要素はエスケープキーを処理するため、オーケストレーションの負担を軽減できます。

スタイル

ダイアログ要素にスタイルを適用する方法は簡単なものと難しいものがあります。簡単な方法では、ダイアログの表示プロパティを変更せず、その制限事項に沿って作業します。ダイアログの開閉や display プロパティの取得など、カスタム アニメーションを提供するハードな方法を採用します。

Open Props を使用したスタイル設定

適応型の色と全体的なデザインの整合性を迅速に実現するため、CSS 変数ライブラリ Open Props を導入しました。提供されている無料の変数に加えて、正規化ファイルといくつかのボタンもインポートします。どちらも、Open Props がオプションのインポートとして提供しています。これらのインポートにより、ダイアログとデモのカスタマイズに集中できます。また、サポートと見栄えを良くするために多くのスタイルを必要としません。

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

表示プロパティの所有

ダイアログ要素のデフォルトの表示と非表示の動作では、表示プロパティが block から none に切り替わります。そのため、アニメーションは「開く」のみで「閉じる」はできません。インとアウトの両方をアニメーション化します。まず、独自の display プロパティを設定します。

dialog {
  display: grid;
}

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

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 回使用されていることに注目してください。最初の方法では、物理ビューポート ユニットである 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;
  }
}

デスクトップとモバイルの両方のメガダイアログが開いているときに、マージン スペースをオーバーレイするデベロッパー ツールのスクリーンショット。

ミニ ダイアログの配置

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

目立たせる

最後に、ダイアログに少し華やかさを加えて、ページの上に浮かぶ柔らかいサーフェスのように見せます。ダイアログの角を丸くすることで、柔らかい印象を与えています。奥行きは、Open Props の丁寧に作られたシャドウ プロップのいずれかによって実現されています。

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

背景疑似要素をカスタマイズする

背景は控えめに扱い、backdrop-filter でメガ ダイアログにぼかし効果を追加するだけにしました。

対応ブラウザ

  • Chrome: 76。
  • Edge: 79.
  • Firefox: 103.
  • Safari: 18。

ソース

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

また、将来的にブラウザで背景要素の遷移が許可されることを期待して、backdrop-filter に遷移を設定しました。

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

カラフルなアバターのぼかし背景に重ねて表示されたメガダイアログのスクリーンショット。

スタイル設定の追加機能

このセクションは、一般的なダイアログ要素というより、ダイアログ要素のデモに関連するものが多いため、「追加機能」と呼んでいます。

スクロール コンテインメント

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

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

対応ブラウザ

  • Chrome: 105。
  • Edge: 105。
  • Firefox: 121。
  • Safari: 15.4。

ソース

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

メガダイアログが開いているときに、html ドキュメントに overflow: hidden が追加されました。

<form> レイアウト

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

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

グリッド レイアウト情報が行の上にオーバーレイ表示されている devtools のスクリーンショット。

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

この要素の役割は、ダイアログ コンテンツのタイトルを指定し、見つけやすい閉じるボタンを提供することです。また、ダイアログの記事コンテンツの背後に表示されるように、サーフェス色も指定します。これらの要件から、フレックスボックス コンテナ、端にスペースを設けて垂直方向に配置されたアイテム、タイトルと閉じるボタンにスペースを確保するためのパディングとギャップが導かれます。

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 デベロッパー ツールのスクリーンショット。

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

このデモでは 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> のスタイル設定

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 デベロッパー ツールのスクリーンショット。

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 プロパティを切り替えるためです。以前は、ガイドではディスプレイがグリッドに設定され、none に設定されることはありませんでした。これにより、アニメーションの開始と終了が可能になります。

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() 関数が呼び出されるとすぐに出力されます。この要素をアニメーション化するため、アニメーションの前後のイベントを用意して、データを取得したり、ダイアログ フォームをリセットしたりできるようにします。ここでは、閉じたダイアログに 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 関数は、トースト コンポーネントの作成でも使用されます。アニメーションと遷移の完了に基づいてプロミスを返します。これが、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 です。ダイアログが削除された場合、メモリを解放するためにクリック イベントとクローズ イベントが削除され、カスタム削除イベントがディスパッチされます。

loading 属性の削除

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

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 イベントでは、ダイアログが閉じられたか、キャンセルされたか、確認されたかを把握することが重要です。確認されると、スクリプトはフォームの値を取得してフォームをリセットします。リセットは、ダイアログが再び表示されたときに空白になり、新しい送信の準備が整うので便利です。

まとめ

私の方法をご覧になったところで、あなたならどうしますか?

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

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

コミュニティ リミックス

リソース