コハム

Webクリエイターのコハムが提供する、Web制作に役立つ情報をまとめたブログです。

【2024年最新】detail・summary・dialog・popoverの徹底解説!もう要素の表示・非表示には悩まない!

The Different (and Modern) Ways to Toggle Content

記事は上記記事を意訳したものです。

※当ブログでの翻訳記事は元サイト様に許可を得て掲載しています。


コンテンツの切り替えとなると、JavaScriptを少し加えたdisplay: noneopacity: 0を使用することが多いでしょう。しかし、今日のWebはより「モダン」になっているので、コンテンツを切り替える様々な方法を俯瞰的に見てみるのに適した時期かもしれません。現在実際にサポートされているネイティブAPIや、それらのメリット・デメリット、そして擬似要素やその他の一見分かりにくい機能についても見ていきましょう。

では、開示要素(<details><summary>)、Dialog API、Popover APIなどについて時間をかけて見ていきましょう。ニーズに応じて、それぞれをいつ使用するのが適切かを検討します。モーダルか非モーダルか?JavaScriptか純粋なHTML/CSSか?迷っていますか?心配いりません、すべて説明していきます。

開示要素(<details><summary>

使用例:コンテンツを独立して切り替え可能な形で要約したり、アコーディオンを作成したりする際のアクセシビリティ対応

時系列順に見ていくと、開示要素(<details><summary>として知られる)は、JavaScriptやチェックボックスを使用した奇妙なハックなしでコンテンツを切り替えられるようになった最初の機能でした。しかし、新機能は当初ブラウザのサポート不足に悩まされ、特にこの機能はキーボードアクセシビリティなしで登場しました。そのため、2011年のChrome 12で登場して以来、使用していない方もいるかもしれません。目の前から消えれば、心からも消えるということですよね?

以下が要点です:

  • JavaScriptなしで機能する(妥協なし)
  • appearance: noneなどを使用せずに完全にスタイリング可能
  • 非標準の疑似セレクタを使用せずにマーカーを非表示にできる
  • 複数の開示要素を接続してアコーディオンを作成できる
  • そして...2024年現在、完全にアニメーション可能

開示要素のマークアップ

必要なのは以下の構造です:

<details>
  <summary>コンテンツの要約(常に表示)</summary>
  コンテンツ(summaryがクリックされると表示が切り替わる)
</details>

内部では、コンテンツは2024年現在::details-contentを使用して選択できる疑似要素でラップされています。さらに、開示要素が開いているか閉じているかを示す::marker疑似要素があり、これはカスタマイズ可能です。

つまり、内部的には開示要素は以下のような構造になっています:

<details>
  <summary><::marker></::marker>コンテンツの要約(常に表示)</summary>
  <::details-content>
      コンテンツ(summaryがクリックされると表示が切り替わる)
  </::details-content>
</details>

デフォルトで開示要素を開いた状態にするには、<details>open属性を付与します。これは開示要素が開かれた時にも内部的に行われることです。

<details open> ... </details>

開示要素のスタイリング

正直なところ、おそらくあの煩わしいマーカーを消したいだけでしょう。その場合は、<summary>displayプロパティをlist-item以外に設定するだけです:

summary {
  display: block; /* またはlist-item以外の任意の値 */
}

また、マーカーを修正することもできます。以下の例ではFont Awesomeを使用して別のアイコンに置き換えていますが、::markerは多くのプロパティをサポートしていないことに注意してください。最も柔軟な回避策は、<summary>のコンテンツを要素でラップしてCSSで選択することです。

<details>
  <summary><span>コンテンツの要約</span></summary>
  コンテンツ
</details>
details {
  
  /* マーカー */
  summary::marker {
    content: "\f150";
    font-family: "Font Awesome 6 Free";
  }

  /* <details>が開いている時のマーカー */
  &[open] summary::marker {
    content: "\f151";
  }
  
  /* ::markerは多くのプロパティをサポートしていないため */
  summary span {
    margin-left: 1ch;
    display: inline-block;
  }
  
}

複数の開示要素でアコーディオンを作成する

アコーディオンを作成するには、複数の開示要素(兄弟要素である必要はない)にname属性と一致する値を付与します(<input type="radio">の実装と同様):

<details name="starWars" open>
  <summary>プリクエル</summary>
  <ul>
    <li>エピソード1:ファントム・メナス</li>
    <li>エピソード2:クローンの攻撃</li>
    <li>エピソード3:シスの復讐</li>
  </ul>
</details>

<details name="starWars">
  <summary>オリジナル</summary>
  <ul>
    <li>エピソード4:新たなる希望</li>
    <li>エピソード5:帝国の逆襲</li>
    <li>エピソード6:ジェダイの帰還</li>
  </ul>
</details>

<details name="starWars">
  <summary>シークエル</summary>
  <ul>
    <li>エピソード7:フォースの覚醒</li>
    <li>エピソード8:最後のジェダイ</li>
    <li>エピソード9:スカイウォーカーの夜明け</li>
  </ul>
</details>

ラッパーを使用すると、これらを水平タブに変換することも可能です:

<div> <!-- Flexラッパー -->
  <details name="starWars" open> ... </details>
  <details name="starWars"> ... </details>
  <details name="starWars"> ... </details>
</div>
div {
  gap: 1ch;
  display: flex;
  position: relative;

  details {
    min-height: 106px; /* コンテンツのシフトを防ぐ */
      
    &[open] summary,
    &[open]::details-content {
      background: #eee;
    }

    &[open]::details-content {
      left: 0;
      position: absolute;
    } 
  }
}

...または、2024年のAnchor Positioning APIを使用して、垂直タブを作成することもできます(同じHTML):

div {
  
  display: inline-grid;
  anchor-name: --wrapper;

  details[open] {
      
    summary,
    &::details-content {
      background: #eee;
    }

    &[open]::details-content {
      position: absolute;
      position-anchor: --wrapper;
      top: anchor(top);
      left: anchor(right);
    } 
  }
}

JavaScriptの機能追加

JavaScriptの機能を追加したい場合:

// オプション:複数の開示要素を選択してループ
document.querySelectorAll("details").forEach(details => {
  details.addEventListener("toggle", () => {
    // 開示要素が切り替えられた
    if (details.open) {
      // 開示要素が開かれた
    } else {
      // 開示要素が閉じられた
    }
  });    
});

アクセシブルな開示要素の作成

いくつかのルールに従えば、開示要素はアクセシブルになります。例えば、<summary>は基本的に<label>のようなもので、フォーカス時にその内容がスクリーンリーダーによって読み上げられます。<summary>がない場合や<summary><details>の直接の子要素でない場合、ユーザーエージェントは通常「Details」と表示および読み上げるラベルを作成します。古いブラウザでは最初の子要素である必要がある場合があるので、その方が安全です。

さらに、<summary>buttonのロールを持っているため、<button>内で無効なものは<summary>内でも無効です。これには見出しも含まれるため、<summary>を見出しのようにスタイリングすることはできますが、実際に見出しを<summary>に挿入することはできません。

Dialog要素(<dialog>

使用例:モーダル

現在、非モーダルオーバーレイにはPopover APIがあるため、show()メソッドで非モーダルダイアログを作成できるとはいえ、ダイアログはモーダルとして考えるのがベストだと思います。popover属性が<dialog>要素より優れている点は、JavaScriptなしで非モーダルオーバーレイを作成できることです。そのため、個人的には非モーダルダイアログにはもはやメリットがないと考えています。明確にするために説明すると、モーダルはメインドキュメントを無効化するオーバーレイであり、非モーダルオーバーレイではメインドキュメントは対話可能なままです。モーダルダイアログには他にもいくつかの標準機能があります:

  • スタイリング可能な背景
  • <dialog>内の最初のフォーカス可能な要素への自動フォーカス(または、バックアップとして<dialog>自体 - この場合はaria-labelを含める)
  • フォーカストラップ(メインドキュメントの無効化の結果として)
  • escキーでダイアログが閉じる
  • ダイアログと背景の両方がアニメーション可能

ダイアログのマークアップと有効化

まず<dialog>要素から始めます:

<dialog> ... </dialog>

デフォルトでは非表示で、<details>と同様に、ページ読み込み時に開いた状態にすることもできます。ただし、この場合showModal()で開かれていないため対話型コンテンツを含まないので、モーダルではありません。

<dialog open> ... </dialog>

この機能が必要になったことはありません。代わりに、ボタンのクリックなどの何らかのインタラクションでダイアログを表示したい場合が多いでしょう。そのためのボタンは以下のようになります:

<button data-dialog="dialogA">dialogAを開く</button>

なぜdata属性を使用するのでしょうか?それは、JavaScriptに対してどのダイアログを開くかを示す識別子を渡したいからです。これにより、以下のように1つのスニペットですべてのダイアログに機能を追加できます:

// その data 属性を持つすべての要素を選択してループ
document.querySelectorAll("[data-dialog]").forEach(button => {
  // インタラクション(クリック)をリッスン
  button.addEventListener("click", () => {
    // 対応するダイアログを選択
    const dialog = document.querySelector(`#${ button.dataset.dialog }`);
    // ダイアログを開く
    dialog.showModal();      
    // ダイアログを閉じる
    dialog.querySelector(".closeDialog").addEventListener("click", () => dialog.close());
  });
});

<dialog>に表示ボタンと関連付けるための一致するidを追加することを忘れないでください:

<dialog id="dialogA"> <!-- idとdata-dialogがdialogA --> ... </dialog>

最後に、"閉じる"ボタンを含めます:

<dialog id="dialogA">
  <button class="closeDialog">dialogAを閉じる</button>
</dialog>

注意:<dialog><form method="dialog">(ボタンを含む)または<button formmethod="dialog"><form>でラップされた)でも閉じることができます。

ダイアログが開いている時のスクロール防止方法

モーダルが開いている間のスクロールを防止するには、CSSで1行です:

body:has(dialog:modal) { overflow: hidden; }

ダイアログの背景のスタイリング

最後に、下層のコンテンツからの注意を逸らすための背景があります(モーダルのみに適用)。そのスタイルは以下のように上書きできます

::backdrop {
  background: hsl(0 0 0 / 90%);
  backdrop-filter: blur(3px); /* 背景専用の楽しいプロパティ! */
}

なお、<dialog>自体にはボーダー、背景色、パディングが設定されており、これらはリセットしたい場合があるでしょう。実際、ポップオーバーも同じ動作をします。

非モーダルダイアログの扱い方

非モーダルダイアログを実装するには:

  • showModal()の代わりにshow()を使用
  • dialog:modalの代わりにdialog[open](両方を対象とする)を使用

ただし、前述の通り、Popover APIはJavaScriptを必要としないため、非モーダルオーバーレイにはそちらを使用するのがベストだと考えています。

Popover API(<element popover>

使用例:非モーダルオーバーレイ

基本的にはポップアップです。適切な使用例には、ツールチップ(またはトグルチップ - その違いを知ることは重要です)、オンボーディングのウォークスルー、通知、切り替え可能なナビゲーション、およびメインドキュメントへのアクセスを失いたくない他の非モーダルオーバーレイが含まれます。これらのユースケースはダイアログとは異なりますが、それでもポップオーバーは非常に素晴らしいものです。機能的にはダイアログと同じですが、モーダルではなくJavaScriptを必要としません。

ポップオーバーのマークアップ

まず、ポップオーバーにはidと、manual値(ポップオーバーの外側をクリックしても閉じない)、auto値(ポップオーバーの外側をクリックすると閉じる)、または値なし(同じ意味)を持つpopover属性が必要です。セマンティックにするために、ポップオーバーは<dialog>にすることができます。

<dialog id="tooltipA" popover> ... </dialog>

次に、ポップオーバーの表示を切り替えたい<button>または<input type="button">popovertarget属性を追加し、値としてポップオーバーのid属性と一致する値を設定します(popovermanualに設定されていない限り、ポップオーバーの外側をクリックすると閉じるため、これはオプションです):

<dialog id="tooltipA" popover>
  <button popovertarget="tooltipA">tooltipAを非表示</button>
</dialog>

メインドキュメントにもう1つ同じようなボタンを配置して、ポップオーバーを表示できるようにします。そうです、popovertargetは実際にトグルになっています(popovertargetaction属性でshowhide、またはtoggleを値として指定しない限り - 後で詳しく説明します)。

ポップオーバーのスタイリング

デフォルトでは、ポップオーバーはトップレイヤー内で中央揃えされます(ダイアログのように)が、モーダルではないため、おそらくそこに配置したくはないでしょう。

<main>
  <button popovertarget="tooltipA">tooltipAを表示</button>
</main>

<dialog id="tooltipA" popover>
  <button popovertarget="tooltipA">tooltipAを非表示</button>
</dialog>

固定位置を使用して簡単にコーナーに配置できますが、ツールチップスタイルのポップオーバーの場合は、それを開くトリガーに対して相対的に配置したいでしょう。CSS Anchor Positioningを使用すると、これが非常に簡単になります:

main [popovertarget] {
  anchor-name: --trigger;
}

[popover] {
  margin: 0;
  position-anchor: --trigger;
  top: calc(anchor(bottom) + 10px);
  justify-self: anchor-center;
}

/* displayプロパティを使用する場合を除き
これも動作しますが必要ありません
[popover]:popover-open {
    ...
}
*/

ただし、これらのアンカーすべてに名前を付ける必要があり、タブ付きコンポーネントの場合は問題ありませんが、多数のツールチップを持つウェブサイトには大げさすぎます。幸いなことに、ボタンのid属性をポップオーバーのanchor属性と一致させることができます。2024年11月現在、サポートは十分ではありませんが、このデモには十分です:

<main>
  <!-- idはanchor属性と一致する必要があります -->
  <button id="anchorA" popovertarget="tooltipA">tooltipAを表示</button>
  <button id="anchorB" popovertarget="tooltipB">tooltipBを表示</button>
</main>

<dialog anchor="anchorA" id="tooltipA" popover>
  <button popovertarget="tooltipA">tooltipAを非表示</button>
</dialog>

<dialog anchor="anchorB" id="tooltipB" popover>
  <button popovertarget="tooltipB">tooltipBを非表示</button>
</dialog>

次の問題は、ツールチップはホバー時に表示されることを期待していますが、これではそうならないため、JavaScriptを使用する必要があるということです。::before/::after/content:を使用してツールチップをより簡単に作成できることを考えると複雑に思えますが、ポップオーバーはHTMLコンテンツを許可します(この場合、私たちのツールチップは実際にはトグルチップです)が、content:はテキストのみを受け入れます。

JavaScript機能の追加

これにつながって...

// すべてのポップオーバートリガーを選択してループ
document.querySelectorAll("main [popovertarget]").forEach((popovertarget) => {
  
  /* 対応するポップオーバーを選択 */
  const popover = document.querySelector(`#${popovertarget.getAttribute("popovertarget")}`);
  
  /* トリガーのmouseover時にポップオーバーを表示 */
  popovertarget.addEventListener("mouseover", () => {
    popover.showPopover();
  });

  /* リンクがない場合、トリガーのmouseout時にポップオーバーを非表示 */
  if (popover.matches(":not(:has(a))")) {
    popovertarget.addEventListener("mouseout", () => {
      popover.hidePopover();
    });
  }
});

まとめ

かなりの量でしたが...これらのAPIが成熟し始めた今、それらを一緒に見ることで、何ができるのか、できないのか、すべきこと、すべきでないことを本当に理解することが重要だと思います。

©コハム