Reactで、右クリックしたときに独自のメニューを出す方法

​​React​createPortal,Playwright,TypeScript,useLayoutEffect,アクセシビリティ

右クリックで独自のメニューを出すには、onContextMenu で preventDefault() を呼んでブラウザのメニューを止め、イベントの clientX と clientY の位置に、position: fixed のメニューを出します。あとは、画面の端で切れないように位置を直す処理と、メニューの外を押したときなどに閉じる処理を足せば、ふだん使うアプリの右クリックメニューに近い動きになります。

ここでは、ファイルの一覧の行を右クリックすると「開く」「お気に入り」「削除」が出るメニューを作りました。キーボードの Shift+F10 やメニューキーで開いて、矢印キーで選べるようにもしています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で右クリックとキー入力をして、画面の大きさを 390×640px にして確かめました。メニューの項目名は短いものだけで試しています。Firefox と Safari、スマートフォンのブラウザ(長押しで開く場合)では試していません。StrictMode を付けた開発ビルドでも同じ操作をしたところ、出力は本番ビルドとまったく同じでした。

ファイルの一覧で「写真.png」を右クリックしてメニューを出し、「お気に入り」を選ぶと行に★が付く。右端で「メモ.txt」を右クリックするとメニューが左に開き、「削除」で行が消える。最後に「報告書.pdf」で開いたメニューを、外をクリックして閉じる
6回の操作で、メニューが開き、選んだ項目で一覧が変わる様子。開いたメニューの枠と、★が付いた行だけをピンクにしている(カーソルと操作の表示は GIF 用に重ねたもの)

右クリックした位置を覚えて、ブラウザのメニューを止める

右クリックすると、その要素で contextmenu イベントが起きます。React では onContextMenu で受け取り、preventDefault() を呼ぶとブラウザのメニューが出なくなります。

メニューを出す位置には、イベントの clientX と clientY を使います。ページ全体の左上からではなく、今見えている画面の左上からの位置です。メニューを position: fixed で置けば、この値をそのまま left と top に使えます。

一覧のコンポーネントでは、どの行で開いたかと、開いた位置を state に持ちます。

tsx
type Menu = { x: number; y: number; name: string };

export function App() {
  const [files, setFiles] = useState(['報告書.pdf', '写真.png', 'メモ.txt']);
  const [stars, setStars] = useState<string[]>([]);
  const [menu, setMenu] = useState<Menu | null>(null);
  const [log, setLog] = useState('');
  const opener = useRef<HTMLElement | null>(null);

  function openMenu(e: React.MouseEvent<HTMLElement>, name: string) {
    e.preventDefault(); // ブラウザのメニューを出さない
    opener.current = e.currentTarget;
    setMenu({ x: e.clientX, y: e.clientY, name });
  }

  // 閉じたら、開く前の行にフォーカスを戻す
  const closeMenu = useCallback(() => {
    setMenu(null);
    opener.current?.focus();
  }, []);

  return (
    <main>
      <ul className="files">
        {files.map((name) => (
          <li
            key={name}
            tabIndex={0}
            className={menu?.name === name ? 'selected' : undefined}
            onContextMenu={(e) => openMenu(e, name)}
          >
            {stars.includes(name) ? `★ ${name}` : name}
          </li>
        ))}
      </ul>
      <p data-testid="log">{log}</p>
      {menu && (
        <ContextMenu
          x={menu.x}
          y={menu.y}
          onClose={closeMenu}
          items={[
            { label: '開く', onSelect: () => setLog(`「${menu.name}」を開く`) },
            {
              label: 'お気に入り',
              onSelect: () =>
                setStars((prev) =>
                  prev.includes(menu.name) ? prev.filter((s) => s !== menu.name) : [...prev, menu.name],
                ),
            },
            {
              label: '削除',
              onSelect: () => setFiles((prev) => prev.filter((f) => f !== menu.name)),
            },
          ]}
        />
      )}
    </main>
  );
}

開いている間は、右クリックした行に selected のクラスを付け、背景を薄い水色にしています。項目の onSelect は menu.name を使うので、どの行で開いたかによって、お気に入りや削除の対象が変わります。

右クリックした結果です。ログの [ページ] の行は、確かめるためにページへ別に付けたリスナーが、contextmenu のたびに書き出したものです。target には、イベントが起きた要素の文字を出しています。そのほかの行は、その場面で見る値だけに絞っています。メニュー は開いているメニューの位置(閉じていれば「なし」)、フォーカス はフォーカスのある要素、一覧 は一覧の行の文字、表示 は「開く」を選んだときに一覧の下に出る文字です。

行の上では defaultPrevented=true になり、メニューの左上が、右クリックした位置(clientX・clientY)と同じになりました。「開く」を選ぶとメニューが閉じて一覧の下に『「写真.png」を開く』と出て、「お気に入り」を選ぶと行に★が付きました。

text
  [ページ] contextmenu button=2 clientX=80 clientY=68 target=写真.png defaultPrevented=true
「写真.png」を右クリック: メニュー left=80 top=68 right=230 bottom=190.78125
「開く」をクリック: メニュー なし / 表示 「写真.png」を開く
  [ページ] contextmenu button=2 clientX=80 clientY=26 target=報告書.pdf defaultPrevented=true
「報告書.pdf」で「お気に入り」: 一覧 [★ 報告書.pdf, 写真.png, メモ.txt]
  [ページ] contextmenu button=2 clientX=200 clientY=400 target=★ 報告書.pdf写真.pngメモ.txt「写真.png」を開く defaultPrevented=false
行のないところを右クリック: メニュー なし

行のないところ(target に一覧とログの文字がまとめて出ている行。行の外の要素です)では onContextMenu が呼ばれないので、defaultPrevented=false のままです。そこではブラウザのメニューが出ることになります(ヘッドレスの Chromium で確かめたので、ブラウザのメニューそのものは画面で見ていません)。

MDN の contextmenu イベントのページによると、Firefox では Shift キーを押しながら右クリックすると、contextmenu イベントが起きずにブラウザのメニューが出ます。この場合は preventDefault() でも止められません。

メニューを body に出し、画面の端では反対側に開く

メニューのコンポーネントは、createPortal で body の直下に出します。公式リファレンスにあるとおり、こうすると親に overflow: hidden などが付いていても、その影響を受けずにページの上に重ねられます。

右クリックした位置のまま出すと、画面の右端や下の端ではメニューが切れます。そこで、useLayoutEffect でメニューの大きさを測り、右に入らなければ左へ、下に入らなければ上へ開くようにしました。useLayoutEffect の中の処理と、その中で更新した state は、ブラウザが画面を描く前に反映されます。そのため、一度はみ出した位置に出てから動いて見えることはありません。

tsx
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';

export type MenuItem = { label: string; onSelect: () => void };

type Props = {
  x: number;
  y: number;
  items: MenuItem[];
  onClose: () => void;
};

const MARGIN = 8;

export function ContextMenu({ x, y, items, onClose }: Props) {
  const ref = useRef<HTMLUListElement>(null);
  const [pos, setPos] = useState({ left: x, top: y });

  // 描く前に大きさを測り、画面からはみ出すなら左や上に開く
  useLayoutEffect(() => {
    const { width, height } = ref.current!.getBoundingClientRect();
    const left = x + width > window.innerWidth - MARGIN ? Math.max(MARGIN, x - width) : x;
    const top = y + height > window.innerHeight - MARGIN ? Math.max(MARGIN, y - height) : y;
    setPos({ left, top });
  }, [x, y]);

  // 開いたら、最初の項目にフォーカスを移す
  useEffect(() => {
    ref.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
  }, [x, y]);

  // (閉じる処理と、キーボードの処理は次の節)

  return createPortal(
    <ul
      ref={ref}
      role="menu"
      className="context-menu"
      style={{ left: pos.left, top: pos.top }}
      onKeyDown={handleKeyDown}
      onContextMenu={(e) => e.preventDefault()}
    >
      {items.map((item) => (
        <li key={item.label} role="none">
          <button
            type="button"
            role="menuitem"
            tabIndex={-1}
            onClick={() => {
              onClose();
              item.onSelect();
            }}
          >
            {item.label}
          </button>
        </li>
      ))}
    </ul>,
    document.body,
  );
}

CSS では、メニューに position: fixed と z-index を付けています。

css
.context-menu { position: fixed; z-index: 10; list-style: none; margin: 0; padding: 4px;
  min-width: 140px; background: #fff; border: 1px solid #c9d3d8; border-radius: 8px;
  box-shadow: 0 4px 16px rgb(0 0 0 / 0.15); }

画面の大きさを 390×640px にして、端の近くで右クリックした結果です。メニューの幅は 150px で、右端の近く(clientX=365)では、右クリックした位置がメニューの右上になるように左へ開きました。右下の端では、右クリックした位置がメニューの右下になりました。

text
  [ページ] contextmenu button=2 clientX=365 clientY=68 target=写真.png defaultPrevented=true
右の端(行の右端)を右クリック: メニュー left=215 top=68 right=365 bottom=190.78125
  [ページ] contextmenu button=2 clientX=365 clientY=625 target=メモ.txt defaultPrevented=true
右下の端(行の右端、画面の下の端)を右クリック: メニュー left=215 top=502.21875 right=365 bottom=625

右下の端の確認では、一覧を画面の下に寄せるため、テストのときだけ一覧の上に余白を足しています。

メニューの外を押したときやスクロールしたときに閉じる

メニューを開いている間は、次のときに閉じます。

  • メニューの外でマウスのボタンを押したとき
  • Esc キーを押したとき
  • ページや、ページの中のスクロールできる要素をスクロールしたとき
  • 画面(ウィンドウ)の大きさが変わったとき

スクロールや大きさの変化で閉じるのは、clientX と clientY で決めた位置が、右クリックした行からずれてしまうためです。

tsx

// メニューの外を押す、Esc、スクロール、画面の大きさの変化で閉じる
  useEffect(() => {
    function handlePointerDown(e: PointerEvent) {
      if (!ref.current?.contains(e.target as Node)) onClose();
    }
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === 'Escape') onClose();
    }
    document.addEventListener('pointerdown', handlePointerDown, true);
    document.addEventListener('keydown', handleKeyDown);
    window.addEventListener('scroll', onClose, true);
    window.addEventListener('resize', onClose);
    return () => {
      document.removeEventListener('pointerdown', handlePointerDown, true);
      document.removeEventListener('keydown', handleKeyDown);
      window.removeEventListener('scroll', onClose, true);
      window.removeEventListener('resize', onClose);
    };
  }, [onClose]);

onClose は、一覧の側で useCallback に包んでいます。公式リファレンスにある、依存の値が変わったときの動きのとおり、包まずに毎回新しい関数を渡すと、再描画のたびにこのリスナーを外して付け直すことになります。

scroll を受け取る addEventListener の第3引数 true は、検証環境の Chromium では外せませんでした。ページの中のスクロールできる要素(overflow: auto の div)を動かして比べたところ、true を付けた版(/)では閉じましたが、true を外した版(/nocapture/)では開いたままでした。

text
$ node test-box.mjs /
開いた直後のメニュー: 1
箱の scrollTop: 100 / ページの scrollY: 0 / 箱をスクロールしたあとのメニュー: 0

$ node test-box.mjs /nocapture/
開いた直後のメニュー: 1
箱の scrollTop: 100 / ページの scrollY: 0 / 箱をスクロールしたあとのメニュー: 1

ほかの閉じ方も確かめました。Esc で閉じたときは、右クリックした行にフォーカスが戻ります。メニューの外をクリックしたときは、フォーカスは body に移りました(クリックした場所に、フォーカスできる要素がないため)。メニューの上で右クリックしても閉じず、contextmenu も defaultPrevented=true になりました(メニューの ul にも onContextMenu を付けているため)。どれも、「写真.png」を右クリックして開き直してから試しています。

text
メニューの外をクリック: メニュー なし / フォーカス body
Esc: メニュー なし / フォーカス li「写真.png」
ホイールでスクロール: メニュー なし / フォーカス li「写真.png」
画面の大きさを変える: メニュー なし / フォーカス li「写真.png」
  [ページ] contextmenu button=2 clientX=155 clientY=129 target=お気に入り defaultPrevented=true
メニューの上で右クリック: メニュー left=80 top=68 right=230 bottom=190.78125 / フォーカス menuitem「お気に入り」

開いたまま別の行を右クリックすると、メニューはその位置に移りました。

text
  [ページ] contextmenu button=2 clientX=50 clientY=68 target=写真.png defaultPrevented=true
「写真.png」を右クリック: メニュー left=50 top=68 right=200 bottom=190.78125
  [ページ] contextmenu button=2 clientX=220 clientY=121 target=メモ.txt defaultPrevented=true
続けて「メモ.txt」を右クリック: メニュー left=220 top=121 right=370 bottom=243.78125

開き直したメニューで項目を選ぶと、対象はあとから右クリックした行になりました(右クリックした位置が右端に近いので、メニューは左へ開いています)。次の出力では、selected のクラスも「メモ.txt」の行に移り、「開く」で「メモ.txt」が開かれています。

text
  [ページ] contextmenu button=2 clientX=80 clientY=26 target=報告書.pdf defaultPrevented=true
「報告書.pdf」を右クリック: メニュー left=80 top=26 right=230 bottom=148.78125
  [ページ] contextmenu button=2 clientX=300 clientY=68 target=メモ.txt defaultPrevented=true
selected の行: メモ.txt
続けて「メモ.txt」を右クリック: メニュー left=150 top=68 right=300 bottom=190.78125
「開く」をクリック: メニュー なし / 表示 「メモ.txt」を開く

キーボードで開いて、矢印キーで選ぶ

マウスを使わない人のために、キーボードでも開けるようにします。一覧の行に tabIndex={0} を付けてフォーカスできるようにすると、行にフォーカスがあるときに Shift+F10 やメニューキーを押すだけで、同じ contextmenu イベントが起きました。追加の処理は要りません。

text
「報告書.pdf」にフォーカス: フォーカス li「★ 報告書.pdf」
「報告書.pdf」の行の位置: left=20 right=370 top=16 bottom=58.59375
  [ページ] contextmenu button=-1 clientX=195 clientY=37 target=★ 報告書.pdf defaultPrevented=true
Shift+F10 を押す: メニュー left=195 top=37 right=345 bottom=159.78125 / フォーカス menuitem「開く」
  [ページ] contextmenu button=-1 clientX=195 clientY=37 target=★ 報告書.pdf defaultPrevented=true
ContextMenu を押す: メニュー left=195 top=37 right=345 bottom=159.78125 / フォーカス menuitem「開く」

ログの ContextMenu は、Playwright でのメニューキーの名前です。キーボードで開いたときは button=-1 になり、clientX と clientY は行の真ん中(左右も上下も中央)の位置でした。行の位置は left=20 right=370 top=16 bottom=58.59375 だったので、真ん中は(195, 37)です。そのため、メニューは行の真ん中から開きます。

MDN には、メニューキーで開いたとき、ブラウザのメニューはフォーカスのある要素の左下に出ると書かれています。一方、検証環境の Chromium で contextmenu イベントに入っていた位置は、行の真ん中でした。ほかのブラウザでの値は確かめていません。

メニューの中のキー操作は、WAI-ARIA の Authoring Practices にある、メニューのキーボード操作に合わせました。開いたら最初の項目にフォーカスを移し、上下の矢印キーで項目を移ります。Tab ではメニューの中を移らず、メニューを閉じます。各項目は role="menuitem" の button なので、Enter で選べます。

tsx

// 上下の矢印キー、Home、End で項目を移る(端まで来たら反対の端へ)
  function handleKeyDown(e: React.KeyboardEvent) {
    const els = [...ref.current!.querySelectorAll<HTMLElement>('[role="menuitem"]')];
    const i = els.indexOf(document.activeElement as HTMLElement);
    let next = -1;
    if (e.key === 'ArrowDown') next = (i + 1) % els.length;
    else if (e.key === 'ArrowUp') next = (i - 1 + els.length) % els.length;
    else if (e.key === 'Home') next = 0;
    else if (e.key === 'End') next = els.length - 1;
    // Tab はメニューを閉じて、ページの次の要素へ進ませる
    else if (e.key === 'Tab') onClose();
    if (next >= 0) {
      e.preventDefault();
      els[next].focus();
    }
  }

項目の tabIndex={-1} は、Tab キーで項目に止まらないようにするためのものです。フォーカスは、矢印キーの処理から focus() で移します。

Shift+F10 で開いてからキーを押した結果です。最後の項目で下の矢印キーを押すと最初の項目へ、最初の項目で上の矢印キーを押すと最後の項目へ移りました。Tab を押すとメニューが閉じ、フォーカスは次の行の「写真.png」に進みました。onClose が開く前の行にフォーカスを戻し、そこから Tab キーの本来の動きで次へ進んだためです。

text
ArrowDown: フォーカス menuitem「お気に入り」
ArrowDown: フォーカス menuitem「削除」
ArrowDown: フォーカス menuitem「開く」
ArrowUp: フォーカス menuitem「削除」
End: フォーカス menuitem「削除」
Home: フォーカス menuitem「開く」
Tab: メニュー なし / フォーカス li「写真.png」

もう一度「報告書.pdf」で開き、「お気に入り」に移って Enter を押すと、★が外れてフォーカスが行に戻りました。一方、「削除」を選んだときは、戻す先の行が消えるので、フォーカスは body になりました。削除のあとに次の行へフォーカスを移す処理は、この記事では作っていません。

text
「お気に入り」で Enter: フォーカス li「報告書.pdf」 / 一覧 [報告書.pdf, 写真.png, メモ.txt]
「写真.png」で「削除」: フォーカス body / 一覧 [報告書.pdf, メモ.txt]

右クリックメニューに要る処理は、次の4つでした。onContextMenu で preventDefault() を呼んで位置を覚えること、createPortal で body の直下に出し、useLayoutEffect で測って画面の端では反対側に開くこと、外のクリックや Esc、スクロール、画面の大きさの変化で閉じること(Esc などでは開く前の行にフォーカスを戻す)、矢印キーで項目を移れるようにすることです。

​Posted by 86