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

<dialog> 要素を使用して、色適応型、レスポンシブ、アクセシビリティ対応のミニモーダルとメガモーダルを構築する方法の基本的な概要。

この記事では、<dialog> 要素を使用して、色適応型でレスポンシブな、アクセシビリティに配慮したミニモーダルとメガモーダルを構築する方法について説明します。デモを試してソースを表示しましょう。

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

動画でご覧になりたい場合は、こちらの YouTube 版をご覧ください。

概要

<dialog> 要素は、ページ内のコンテキスト情報やアクションに最適です。フォームが小さい場合や、ユーザーに求められる操作が確認またはキャンセルのみの場合など、複数ページのアクションではなく、同じページのアクションがユーザー エクスペリエンスの向上につながる場合を検討します。

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

Browser Support

  • Chrome: 37.
  • Edge: 79.
  • Firefox: 98.
  • Safari: 15.4.

Source

要素にいくつかの欠落があることがわかったため、この 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 を使用して要素からフォーカスが離れるのを監視し、離れた時点でインターセプトして戻していました。

Browser Support

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

Source

inert の後、ドキュメントのどの部分も「フリーズ」できます。つまり、フォーカス ターゲットではなくなり、マウスで操作できなくなります。フォーカスをトラップするのではなく、ドキュメントの唯一のインタラクティブな部分にフォーカスが誘導されます。

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

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

Esc キーで閉じる

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

スタイル

ダイアログ要素のスタイル設定には、簡単な方法と難しい方法があります。簡単な方法は、ダイアログの display プロパティを変更せずに、その制限内で動作させることです。ダイアログの開閉用にカスタム アニメーションを提供し、display プロパティなどを引き継ぐという難しい道を進みます。

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

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

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

表示プロパティの所有

ダイアログ要素のデフォルトの表示 / 非表示の動作では、display プロパティが block から none に切り替わります。残念ながら、この場合、アニメーションはインのみで、インとアウトはできません。両方をフェードイン / フェードアウトさせたいので、まず独自の display プロパティを設定します。

dialog {
  display: grid;
}

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

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

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

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

ライトモードとダークモードを表示し、サーフェス カラーを説明するメガ ダイアログ。

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 でぼかし効果を追加するだけにしました。

Browser Support

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

Source

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

また、ブラウザが将来的に背景要素のトランジションを許可することを期待して、backdrop-filter にトランジションを適用しました。

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

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

スタイリングの追加パラメータ

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

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

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

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

Browser Support

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

Source

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;
}

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

ダイアログ <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 を使用した折り返し可能な 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 デベロッパー ツールのスクリーンショット。

アニメーション

ダイアログ要素は、ウィンドウに出入りするため、アニメーション化されることがよくあります。ダイアログの開始と終了にサポート的なモーションを加えることで、ユーザーはフローの中で自分の位置を把握しやすくなります。

通常、ダイアログ要素はアニメーションで表示することのみが可能で、非表示にすることはできません。これは、ブラウザが要素の 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 イベントが付属しています。このイベントは、ダイアログの close() 関数が呼び出されるとすぐに発行されます。この要素をアニメーション化しているので、アニメーションの前後のイベントがあると、データを取得したり、ダイアログ フォームをリセットしたりするのに便利です。ここでは、閉じたダイアログの inert 属性の追加を管理するために使用しています。デモでは、ユーザーが新しい画像を送信した場合にアバター リストを変更するために使用しています。

これを行うには、closingclosed という 2 つの新しいイベントを作成します。次に、ダイアログの組み込みの close イベントをリッスンします。ここで、ダイアログを 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 し、自信を持って閉じたイベントに進むことができます。

開始イベントと開始済みイベントを追加する

これらのイベントは、組み込みのダイアログ要素に close のような open イベントがないため、簡単に追加できません。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 属性の削除

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

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

ページ読み込み時のキーフレーム アニメーションの防止に関する問題の詳細をご覧ください。

All together

各セクションの説明が終わったので、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()
  }
}

ダイアログ要素で作成したデモでは、この closed イベントとフォームデータを使用して、リストに新しいアバター要素を追加しています。ダイアログの終了アニメーションが完了してから、新しいアバターをアニメーションで表示するスクリプトが実行されるため、タイミングは適切です。新しいイベントにより、ユーザー エクスペリエンスのオーケストレーションがよりスムーズになります。

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

まとめ

私がどのように行ったかをご理解いただけたかと思います。では、あなたならどのようにしますか?🙂

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

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

コミュニティ リミックス

リソース