ReactでPopover APIを使い、メニューを表示する方法

​​React​CSS,Playwright,Popover API,TypeScript

React 19 では、popover と popoverTarget を JSX にそのまま書けます。ボタンに popoverTarget、メニューに popover を付けるだけで、ボタンで開閉するメニューになります。メニューの外を押したときや Esc キーで閉じる処理、ほかの要素より手前に出す処理は、ブラウザが受け持ちます。React で書くのは、選んだ値の state と、選んだら閉じる処理くらいです。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chrome for Testing 153(Chromium)を Playwright 1.64.1 で操作して確かめました。Firefox や Safari、スマホの実機、スクリーンリーダーでの読み上げ、サーバーでのレンダリング(SSR)は試していません。

popover と popoverTarget を付けて、ボタンで開閉する

React の変更履歴によると、Popover API への対応は 19.0.0 で加わりました。検証環境の @types/react 19.3.0 にも、次の型がありました。

text
2940:        popover?: "" | "auto" | "manual" | "hint" | undefined;
2941:        popoverTargetAction?: "toggle" | "show" | "hide" | undefined;
2942:        popoverTarget?: string | undefined;

例として、表示する猫を選ぶメニューを作ります。ボタンを押すとメニューが開き、項目を押すとボタンの文字が変わってメニューが閉じます。

tsx
import { useId, useRef, useState } from 'react';
import './menu.css';

const cats = ['ミケ', 'クロ', 'シロ'];

export function CatMenu() {
  const menuId = useId();
  const menuRef = useRef<HTMLDivElement>(null);
  const [cat, setCat] = useState('ミケ');
  const [open, setOpen] = useState(false);

  function choose(name: string) {
    setCat(name);
    // 選んだらメニューを閉じる
    menuRef.current?.hidePopover();
  }

  return (
    <div className="toolbar">
      <button type="button" className="menu-button" popoverTarget={menuId}>
        表示する猫:{cat}
      </button>
      <div
        id={menuId}
        ref={menuRef}
        popover="auto"
        className="menu"
        onToggle={(e) => setOpen(e.newState === 'open')}
      >
        {cats.map((name) => (
          <button
            key={name}
            type="button"
            className="menu-item"
            aria-current={name === cat}
            onClick={() => choose(name)}
          >
            {name}
          </button>
        ))}
      </div>
      <p className="state">メニュー:{open ? '開いている' : '閉じている'}</p>
    </div>
  );
}

popoverTarget には、メニューの id を文字列で渡します。型も string です。id は useId で作ると、同じ部品を画面に何個置いても重なりません。React が描画したボタンとメニューの属性を調べると、次のようになっていました。

text
button: popovertarget="_r_0_"  menu: id="_r_0_" popover="auto"  popoverTargetElement が menu: true

ボタンには popovertarget 属性が付き、ブラウザもこのボタンの操作先をメニューとして扱っていました(popoverTargetElement がメニューを指していました)。MDN の button 要素のページにあるとおり、popovertargetaction を省くと、ボタンは開閉を切り替える toggle として動きます。ボタンを2回押した結果です。

text
1回目: 開いている true / 表示する猫:ミケ / メニュー:開いている / フォーカス メニューのボタン
2回目: 開いている false / 表示する猫:ミケ / メニュー:閉じている / フォーカス メニューのボタン

onClick も useState の開閉フラグもなしで、1回目で開き、2回目で閉じました。

「表示する猫:ミケ」のボタンを押すとメニューが開き、「シロ」を押すとボタンの文字が「シロ」に変わってメニューが閉じる。もう一度開いてから、メニューの外を押すと閉じる。変わった表示は、押した直後だけピンクになる
ボタンで開き、項目を選んで閉じ、外を押して閉じるまでの動き

項目を選んだら hidePopover で閉じる

項目のボタンを押しても、メニューは自動では閉じません。そこで choose の中で、メニューの要素の hidePopover() を呼んでいます。要素は useRef で受け取ります。「クロ」を押した結果です。

text
選んだ後: 開いている false / 表示する猫:クロ / メニュー:閉じている / フォーカス メニューのボタン

state が変わってボタンの文字が「クロ」になり、メニューは閉じました。押した項目はメニューと一緒に隠れましたが、フォーカスは失われず、メニューを開いたボタンに戻りました。

外側のクリックと Esc キーは、ブラウザが閉じてくれる

popover="auto"(値を省いた popover も同じ)のメニューは、外側を押すと閉じます。MDN の Popover API の使い方のページでは、これを「light dismiss」と呼んでいます。同じページには、Esc キーを押すなど、ブラウザが用意している操作でも閉じられると書かれています。メニューを開いてから外を押した結果と、キーボードで操作した結果です。

text
開いた: 開いている true / 表示する猫:クロ / メニュー:開いている / フォーカス メニューのボタン
外を押した後: 開いている false / 表示する猫:クロ / メニュー:閉じている / フォーカス BODY
text
Enter の後: 開いている true / 表示する猫:クロ / メニュー:開いている / フォーカス メニューのボタン
Tab の後: 開いている true / 表示する猫:クロ / メニュー:開いている / フォーカス 項目「ミケ」
Esc の後: 開いている false / 表示する猫:クロ / メニュー:閉じている / フォーカス メニューのボタン

ボタンにフォーカスを当てて Enter キーを押すと開き、Tab キーを押すとメニューの最初の項目にフォーカスが移りました。MDN のページにある、ポップオーバーのアクセシビリティの説明によると、開いたメニューは、フォーカスの順番でボタンの次に来るように扱われます。Esc キーを押すと閉じ、フォーカスはボタンに戻りました。

同じ説明には、aria-expanded を書かなくても、ブラウザがボタンとメニューを結びつけ、開いているかどうかを支援技術に伝える、とも書かれています。Chromium のアクセシビリティツリーで、ボタンの「展開」の状態を読みました。

text
閉じているとき ボタンの expanded: false / aria-expanded 属性: null
開いているとき ボタンの expanded: true / aria-expanded 属性: null

aria-expanded 属性は書いていませんが、メニューが開いているときは、ボタンが展開した状態として扱われていました。

auto のメニューは、ふつうは同時に1つしか開きません。別の auto のメニューを開くと、先に開いていたものは閉じます(MDN の同じ節)。検証では、メニューを2つ置き、片方を開いたまま、もう片方を showPopover() で開きました。外側のクリックで閉じたのと区別するため、クリックではなくメソッドで開いています。先に開いていたメニューは閉じました。

text
猫のメニュー true / 箱の中のメニュー false

position-area で、ボタンのすぐ下に出す

開いたメニューは、何も指定しないと画面の真ん中に出ます。position-area の指定を外して測ると、幅 480px・高さ 400px の画面で、幅 90px・高さ 130px のメニューが、左から 195px、上から 135px の位置に出ました。

text
position-area なし: メニュー left 195 top 135 / 画面の幅 480 高さ 400 / メニューの幅 90 高さ 130

ボタンの下に出すには、CSS の position-area を使います。

css
/* ボタンのすぐ下に、左端をそろえて出す */
.menu {
  padding: 4px;
  border: 1px solid #9aa5ab;
  border-radius: 8px;
  position-area: bottom span-right;
}

anchor-name や position-anchor は書いていません。MDN にある、ポップオーバーを呼び出し元のボタンに合わせて置く方法の説明によると、popovertarget でボタンとメニューを結びつけると、ボタンがメニューの暗黙のアンカーになり、position-area で置くときは、そのアンカーが自動で使われます。bottom span-right は、MDN の position-area のページにある 3×3 のマスで言うと、ボタンの下の段のうち、真ん中と右の2マスを使う指定です。結果として、メニューの左端がボタンの左端にそろいます。

text
ボタン left 8 bottom 52 / メニュー left 8 top 52

メニューの上端は、ボタンの下端(52px)に接し、左端(8px)もそろいました。

overflow: hidden の箱の中でも、メニューは切れない

MDN の Popover API の使い方のページによると、開いたポップオーバーは top layer に置かれ、ほかのページの中身より手前に出ます。高さ 60px で overflow: hidden の箱の中に、同じ作りのメニューを置いて試しました。

tsx
<div className="clip-box" style={{ overflow: 'hidden', height: 60, border: '1px dashed #9aa5ab' }}>
  <button type="button" className="menu-button" popoverTarget={id}>
    箱の中のメニュー
  </button>
  <div id={id} popover="auto" className="menu clip-menu">
    <button type="button" className="menu-item">項目1</button>
    <button type="button" className="menu-item">項目2</button>
    <button type="button" className="menu-item">項目3</button>
  </div>
</div>
text
箱の bottom 170 / メニューの bottom 283 / 「項目3」の位置で一番上にある要素: 項目3

メニューは箱の下端(170px)を越えて 283px まで出ていて、箱からはみ出た「項目3」の位置でも、一番上にある要素は「項目3」でした。切れずに表示され、ほかの要素にも隠れていませんでした。

点線の箱の中にある「箱の中のメニュー」のボタンの下に、項目1から項目3までのメニューが、箱の下端を越えて表示されている
overflow: hidden の箱の中に置いたメニューを開いた画面

開いているかどうかは onToggle で受け取る

メニューが開いているかどうかを React の state にしたいときは、onToggle を使います。外側を押したときや Esc キーで閉じたときも、ブラウザが閉じたことを state に反映できます。

tsx
onToggle={(e) => setOpen(e.newState === 'open')}

MDN の toggle イベントのページにあるとおり、開いたときは newState が open、閉じたときは closed になります。ここまでの出力の「メニュー:開いている/閉じている」が、この state です。ボタン、項目の hidePopover()、外側のクリック、Esc キーのどれで開閉しても、:popover-open で読んだ実際の状態と合っていました。

toggle イベントは、開閉が終わったあとに届きます。同じページにある、イベントがまとめられる場合の説明によると、同じ処理の中で続けて開閉すると(たとえば showPopover() の直後に hidePopover() を呼ぶと)、toggle イベントは1回しか届きません。検証では、操作のあと 50ms 待ってから state を読みました。開く前に何かをしたいときは、開く直前に届く beforetoggle イベントがあり、@types/react 19.3.0 には onBeforeToggle の型もありました。onBeforeToggle は、この記事では動かしていません。

ポイント

ボタンに popoverTarget={id}、メニューに popover と同じ id を付けると、開閉、外側のクリックと Esc キーで閉じる処理、手前に出す処理はブラウザが受け持ちます。React では、項目を選んだら ref から hidePopover() を呼び、置き場所は CSS の position-area で決めます。開閉の状態が必要なら onToggle の newState を state に入れます。

​Posted by 86