階層メニューやトーストUIが簡単に作れる新技術! JavaScriptで利用するポップオーバーAPI

124
39

2023年5月〜6月にリリースされたChrome 114とEdge 114には、「ポップオーバーAPI」というAPIが搭載されました。

ポップオーバーとはコンテンツの1番上に重ねて表示するUIで、ユーザーにアクションを促したり、補足の情報などを伝えるために画面に表示します。ポップオーバーAPIのMDNのドキュメントではオーバーレイ、ポップアップ、ポップオーバー、ダイアログなどを総称して「ポップオーバー」と呼んでいます。

ウェブサイトでよく見かけるポップオーバーですが、実装するには意外と調整や考慮の多いUIです。たとえば、画面の1番上に重ねるためにはz-indexで他の要素との重なり順を調整する必要があります。Escキーを押した時や要素外をクリックした時にポップオーバーを閉じるには、JavaScriptで制御を追加します。ポップオーバーが複数あった場合どうでしょう? 1つだけ表示するのか、すべて表示したままにするのか? その場合は重なり順や閉じる挙動はどうなるのか? など、少し考えただけでさまざまな課題が出てきます。

今回紹介するポップオーバーAPIは上記のような機能は標準で搭載されているため、簡単に最低限の機能を備えたポップオーバーを実装できます。この記事ではポップオーバーAPIを使ってどのようなことができるのか、作例を交えながら紹介します(※本記事の作例はChrome 114・Edge 114以上で閲覧ください)。

ポップオーバーとモーダルの違い

APIの解説に入る前に、ポップオーバーとモーダルの違いについて確認しましょう。

ポップオーバーもモーダルもどちらも画面の1番上に重ねて表示するUIですが、決定的な違いは表示している間に他の要素の操作を許すかどうかです。モーダルは表示されている間スクロールや他の要素のクリックなどの操作はできません。それに対し、今回紹介するポップオーバーは表示されている間も他の要素を操作できるUIです。

もし他の操作をブロックするモーダルを作成したい場合は、ポップオーバーAPIではなくdialog要素で作成すると良いでしょう。『overscroll-behaviorがお手軽!モーダルUI等のスクロール連鎖を防ぐ待望のCSS』でdialog要素を使ったモーダルの作り方を解説しているので、興味のある方はご確認ください。

1. JavaScriptを使わないポップオーバー

ポップオーバーAPIを使うと、JavaScriptなしで以下のようなポップオーバーを作れます。

JavaScriptを使わないポップオーバー

HTML属性を指定するだけで実装できる

このポップオーバーの実装方法はいたってシンプルです。

  1. ポップオーバー本体にpopover属性を付与し、任意のidを与えます(id="popover1"など)。
  2. ポップオーバーの開閉を制御したいボタンにpopovertarget属性を付与し、1で与えたidを指定します。
<!-- ▼ポップオーバーを制御するボタン -->
<button popovertarget="popover1">Popover 1</button>
<!-- ▼ポップオーバー本体 -->
<div id="popover1" popover class="popover-content">
  <p>JavaScriptを使わずに最低限の機能が実現できます。</p>
</div>

<!-- ▼ポップオーバーを制御するボタン -->
<button popovertarget="popover2">Popover 2</button>
<!-- ▼ポップオーバー本体 -->
<div id="popover2" popover class="popover-content">
  <p>2つめのポップオーバーを開くと、1つめは自動的に閉じます</p>
</div>

これだけでシンプルなポップオーバーの完成です。このように簡単に実装できるうえ、ポップオーバーAPIには便利な機能が標準で搭載されています。

ポップオーバーAPIに標準で搭載されている機能

(1)最上位レイヤーに配置される

このAPIを使ったポップオーバー本体はデフォルトで最上位レイヤーに配置されます。このレイヤーは特別で、他の要素のz-indexの値などにかかわらず、必ず画面の1番上に表示されます。

(2)簡単な解除が可能

「ポップオーバーの外側をクリックする」もしくは「Escキーを押す」ことで、ポップオーバーを閉じることができます。MDNのドキュメントではこれを「簡単な解除(light dismissed)」と呼んでいます。

(3)複数のポップオーバーがあった場合は勝手に他を閉じてくれる

通常、ポップオーバーは画面に1つだけ表示できます。そのため、2つのポップオーバーがあった場合に1つめを開いた状態で2つめを開くと、1つめを自動的に閉じてくれます。

「簡単な解除」と「ポップオーバーは画面に1つだけ」の挙動は変更できます。詳しくは2つめの作例で紹介します。

ポップオーバーにスタイルを適用する

ポップオーバーにスタイルを適用する際は、以下の疑似要素と疑似クラスを使います。

  • :popover-open: ポップオーバーが開いた状態を指す疑似クラス。
  • ::backdrop: ポップオーバーの後ろに背景として配置される疑似要素。
/* ⭐️:popover-openを使って表示時のスタイルを適用 */
.popover-content:popover-open {
  width: 300px;
  height: 120px;
  border-radius: 8px;
  border: none;
  padding: 24px;
  box-shadow: 8px 8px 10px #707070;
  background: #ffffff;
}
/* ⭐️背景にスタイルを適用する場合は::backdropを使用する */
.popover-content::backdrop {
  background-color: #505050;
  opacity: 0.5;
}

2. トースト

ユーザーに処理が完了したことやエラーを知らせるトーストをポップオーバーAPIで実装した例です。

ポップオーバーAPIを使ったトースト

ポップオーバーの表示/非表示を手動で切り替える

1つめの例ではHTMLにpopoverpopovertargetといった属性を指定することで、ポップオーバーの表示/非表示を実装しました。今回はJavaScriptを使用して手動で表示/非表示を切り替えてみます。

ここでポイントとなる点は以下の4つです。

  1. トーストとして作成したdiv要素のpopover属性にmanualを指定します。こうすることで「簡単な解除」と「画面にポップオーバーは1つだけ」の機能を持たないポップオーバーを作成できます。
  2. トーストを表示する際にshowPopover()メソッドを使用します。このメソッドで表示すると最上位レイヤーに配置されます。
  3. トーストの非表示にはhidePopover()メソッドを使用します。
  4. hidePopover()だけだとDOMに要素が残ったままなので、remove()メソッドでDOMから削除します。

以下は作例を一部抜粋したものです。このようにJavaScriptを使うとさらに柔軟な実装ができます。

const setupToast = ({ message, cssName }) => {
  // トーストをDOMに追加する
  const toast = createToastElm(message, cssName);
  document.body.appendChild(toast);
  // ⭐️showPopoverメソッドで表示する
  toast.showPopover();

  // -----省略-----
};

/**
 * トーストを作成します。
 * @param {string} message 表示するメッセージ
 * @param {string} cssName cssのクラス名
 * @return {HTMLDivElement} 作成したトーストエレメント
 */
const createToastElm = (message, cssName) => {
  const toast = document.createElement("div");
  // ⭐️popover属性に"manual"を指定する
  toast.popover = "manual";

  // -----省略-----

  // 閉じるボタン
  const closeButton = document.createElement("button");
  closeButton.addEventListener("click", () => removeToast(toast));
  toast.appendChild(closeButton);
  return toast;
};

/**
 * トーストを削除します。
 * @param {HTMLDivElement} toast 削除したいトースト
 */
const removeToast = (toast) => {
  // ⭐️hidePopoverメソッドで非表示にする
  toast.hidePopover();
  // ⭐️非表示にした後にDOMから削除する
  toast.remove();
};

toggleイベントの検知

この作例では、新しいトーストが作成された時と削除されたときに並び替えを行いアニメーションをつけています。一連の処理は以下のような流れになっています。

  1. トーストの表示/非表示を検知する
  2. アニメーションを行う
    • 表示(新しいトーストが作成された)時:1番下に新しいトーストをふわっと表示する
    • 非表示(トーストが削除された)時:全体をふわっと上に移動する

このトーストの表示/非表示を検知するために使っているのがtoggleイベントです。

toggleイベントはポップオーバー要素独自のイベントで、表示または非表示になった直後に発行されます。このイベントに渡されるeventオブジェクトのnewStateの値を使ってさらにアニメーションを分岐させています。

// トーストの表示時と非表示時に並び替える
toast.addEventListener("toggle", (event) => {
  alignToast(event.newState === "closed");
});

// -----省略-----

const alignToast = (withMoveAnim) => {
  const toasts = document.querySelectorAll(".toast");
  // トーストを順番に縦に並べる
  // withMoveAnimがtrue:opacityとtranslateのアニメーション
  // withMoveAnimがfalse:opacityのアニメーション
  toasts.forEach((toast, index) => {
    toast.style.transition = withMoveAnim
      ? "translate 0.2s linear, opacity 0.2s linear"
      : "opacity 0.2s linear";
    toast.style.translate = `0px ${(56 + 10) * index}px`;
    toast.style.opacity = 1;
  });
};

event.newStateの値がclosedの時は、トーストが消えたので画面の残りのトーストを上に移動するアニメーションを行い、それ以外(event.newStateの値はopenになります)の場合はふわっと表示するアニメーションを行います。

アニメーション以外にも、トーストを表示した後3秒後に自動的に削除する実装も行なっています。興味がある方はソースコードを確認してみてください。

3. 入れ子になったポップオーバー

入れ子になったメニューをポップオーバーAPIで実装した例です。ポップオーバーを入れ子にすることで「ポップオーバーは画面に1つだけ」の機能を持たずに作成できます。

入れ子になったポップオーバー

キーボードのフォーカスやマウスオーバーに対応する

トーストの作例とやっていることはほぼ変わりませんが、この例ではキーボードのフォーカスやマウスオーバーでメニューを開くようにしています。

mouseenterfocusinといったイベントを検知して制御します。:popover-openの状態によって、すでに開いている場合やすでに閉じている場合には処理を行わないようif文で分岐しています。

const popoverContainers = document.querySelectorAll(".popover-container");
// -----省略-----

popoverContainers.forEach((container) => {
  // マウス操作の制御
  container.addEventListener("mouseenter", () => openPopoverOf(container));
  container.addEventListener("mouseleave", () => closePopoverOf(container));
  // キーボード操作の制御
  container.addEventListener("focusin", () => openPopoverOf(container));
  // -----省略-----
});

const openPopoverOf = (container) => {
  // containerから一番近いポップオーバーを取得する
  const popover = container.querySelector(".popover");
  if (popover == null) {
    return;
  }
  // まだ開いていない場合だけshowPopoverを呼ぶ
  if (!popover.matches(":popover-open")) {
    popover.showPopover();
  }
};

const closePopoverOf = (container) => {
  // containerから一番近いポップオーバーを取得する
  const popover = container.querySelector(".popover");
  if (popover == null) {
    return;
  }
  // まだ閉じていない場合だけhidePopoverを呼ぶ
  if (popover.matches(":popover-open")) {
    popover.hidePopover();
  }
};

// -----省略-----

4. ツールチップ

最後に紹介するのは、ポップオーバーAPIを使用したツールチップです。

この作例ではツールチップの位置を制御するため『CSS Anchor Positioning API』を使っています。このAPIは2024年5月リリースのChrome 125から使用可能となりました。ポップオーバーAPIと一緒に使うと便利なので紹介します。

ツールチップ

anchor属性でポップオーバーの親子関係を定義する

ポップオーバーAPIにはanchor属性というものがあります(これは先ほど紹介したCSS Anchor Positioning APIとはまた別のものです)。anchor属性を設定すると、HTMLの構造的に入れ子にしなくてもポップオーバーの親子関係を定義できます。

  1. 親としたい要素(この作例ではa要素)にidを設定します。
  2. 子に設定したいポップオーバー(この作例ではdiv要素)のanchor属性に、1のidと同じ名前を設定します。
<div class="container">

  <h1>一、
    <!-- ⭐️親としたい要素にidを設定 -->
    <a href="#" id="afternoon">午后</a>
    の授業
  </h1>
  <p>「ではみなさんは、そういうふうに川だと云われたり...</p>

  <!-- 省略 -->

  <!-- ⭐️子にしたい要素のanchorに親のidを設定する -->
  <div class="tooltip" popover="manual" anchor="afternoon">午後のこと。</div>
</div>

a要素とポップオーバーであるdiv要素はHTML構造としては入れ子になってはいませんが、このようにanchor属性を用いることで「親子である」と紐づけることができます。

ポップオーバーの位置を指定する

ツールチップの位置をCSSで調整しましょう。ここでCSS Anchor Positioning APIが登場します。 先ほどのポップオーバーの親子関係の定義と似ていますが、まずは次の手順で基準となる要素とツールチップとの紐づけを行います。

  1. 基準としたい要素(この作例ではa要素)にanchor-nameプロパティを設定して、値には一意になるような名前を入れます。ルールとして、たとえば--anchor-elといった形で先頭にダッシュを2つ付けます。
  2. ツールチップ要素(この作例ではdiv要素)にposition-anchorプロパティを設定して、anchor-nameプロパティで設定したものと同じ値を入れます。
<div class="container">
  <h1>
    一、
    <!-- ⭐️基準としたい要素にanchor-nameプロパティを設定し、一意な名前を入れる -->
    <a href="#" id="afternoon" style="anchor-name: --afternoon">午后</a>
    の授業
  </h1>
  <p>「ではみなさんは、そういうふうに川だと云われたり...</p>

  <!-- 省略 -->

  <!-- ⭐️ツールチップ要素にposition-anchorプロパティを設定し、anchor-nameと同じ値を入れる -->
  <div
    class="tooltip"
    popover="manual"
    anchor="afternoon"
    style="position-anchor: --afternoon"
  >
    午後のこと。
  </div>
</div>

続いて、inset-areaプロパティでツールチップの表示位置を調整します。a要素に対して、どの位置にツールチップを表示するか指定できます。今回はツールチップをa要素の左上に出したいので、span-right topを指定します。指定方法について、詳しくはドキュメントを確認ください。

また、inset-areaの値による表示場所の変化は以下のサイトがわかりやすいので、ぜひ参考ください。

さらに、ポップオーバーに付いているブラウザのデフォルトスタイルにより、ツールチップの位置がずれてしまうことがあります。その場合は、marginプロパティは0を指定して初期値にリセットします。今回はmargin-bottomのみ、レイアウトに必要なため4pxを指定しています。

.tooltip {
  inset-area: span-right top;
  margin: 0 0 4px;
  /** -----省略----- */
}

このように記述することで、a要素の左上にツールチップを配置することができました。CSS Anchor Positioning APIで配置を行うと、ウィンドウサイズを変更してもブラウザ側で勝手にツールチップの位置を変更してくれます。

ツールチップ

対応ブラウザ

ポップオーバーAPIは2024年4月にFireFox 125でサポートされたことで、すべての主要なブラウザで使用可能になりました(参照:Can I use…)。

ポップオーバーAPIの各ブラウザの対応状況

まとめ

ここまでポップオーバーAPIについて紹介しました。今まで自分で複雑な実装を行っていたUIも、このような新しいAPIを使うことで簡単に記述できるようになります。新しい機能にキャッチアップするのは大変でもありますが、今までできなかったことができるようになるのはワクワクしませんか? ぜひ皆さんも実際に使ってみてください!

参考サイト

北川 杏子

アパレル、事務を経てエンジニアに転身。フルスタックエンジニアとしてバックエンド、フロントエンド両方の開発を経験したのちICSに入社。特技は英語。

この担当の記事一覧