Reactで、入れ子になったドロップダウンメニューを作る方法

​​React​Playwright,Pointer Events,TypeScript,アクセシビリティ,キーボード操作

入れ子のドロップダウンメニューは、メニューの中身を children を持つ木の形のデータで書き、メニューを描く関数の中で同じ関数をもう一度呼んで(再帰で)作ります。どのサブメニューが開いているかは、開いている親の項目の id を上の階層から並べた配列1つで持ちます。こうすると、同じ階層で開いているサブメニューは必ず1つになり、「1つ上の階層に戻る」は配列の末尾を削るだけで済みます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium にポインターの移動、クリック、キー入力を送って確かめました。アプリ全体を StrictMode で囲んだ場合も、同じ操作で同じ結果になりました。

Firefox と Safari、タッチ操作の端末、スクリーンリーダーでは試していません。画面の右端でサブメニューを左側に開き直す処理も、この記事のコードには入れていません。

上の帯に「操作 1回目/4回」などの回数と、何をするか、開いているメニューの段数が出る。「メニュー」ボタンを押すとメニューが開き、「書き出す」にポインターを乗せると右にサブメニューが開き、「画像」に乗せるとさらに右に3段目が開く。「PNG」を押すとメニューがすべて閉じ、下に「選んだ項目:書き出す > 画像 > PNG」がピンクで出る
ボタンで開き、書き出す、画像とポインターを乗せて PNG を選ぶ動き(上の帯、ポインターの矢印、ピンクの強調は GIF 用に足したもの)

メニューの中身を、children を持つ木の形で書く

メニューの項目は、次の形のオブジェクトの配列で渡します。children を持つ項目が、サブメニューを開く親の項目です。

ts
export type MenuItem = {
  id: string;
  label: string;
  children?: MenuItem[]; // あれば、この項目からサブメニューが開く
};

export const menuItems: MenuItem[] = [
  { id: 'new', label: '新規作成' },
  { id: 'open', label: '開く' },
  {
    id: 'export',
    label: '書き出す',
    children: [
      { id: 'pdf', label: 'PDF' },
      {
        id: 'image',
        label: '画像',
        children: [
          { id: 'png', label: 'PNG' },
          { id: 'jpeg', label: 'JPEG' },
          { id: 'webp', label: 'WebP' },
        ],
      },
      { id: 'text', label: 'テキスト' },
    ],
  },
  {
    id: 'share',
    label: '共有',
    children: [
      { id: 'copy-link', label: 'リンクをコピー' },
      { id: 'mail', label: 'メールで送る' },
    ],
  },
  { id: 'delete', label: '削除' },
];

children の中にも同じ形の項目が入るので、階層は何段でも足せます。この例では「書き出す」の下の「画像」が、3段目のメニューを持っています。

開いているサブメニューを、親の id の配列で持つ

コンポーネントの state は、ボタンから開く一番上のメニューが開いているかどうか(open)と、開いているサブメニューの親の id の配列(openPath)の2つです。

tsx

const [open, setOpen] = useState(false);
  // 開いているサブメニューの親の id を、上の階層から順に持つ。[] ならサブメニューは開いていない
  const [openPath, setOpenPath] = useState<string[]>([]);

「書き出す」の下の「画像」まで開いているときは、openPath は ['export', 'image'] になります。メニューを描く renderMenu は、level 階層目の項目のうち openPath[level] と id が同じものだけを開いた状態にして、その下に自分自身を呼んでサブメニューを描きます。

tsx

const renderMenu = (list: MenuItem[], level: number, parents: MenuItem[]) => {
    const parent = parents.at(-1) ?? null;
    return (
      <ul
        role="menu"
        id={menuId(parent ? parent.id : 'root')}
        aria-labelledby={parent ? itemId(parent.id) : `${baseId}-button`}
        className={level === 0 ? 'menu' : 'menu submenu'}
      >
        {list.map((item) => {
          const expanded = openPath[level] === item.id;
          return (
            <li role="none" key={item.id}>
              <div
                role="menuitem"
                id={itemId(item.id)}
                tabIndex={-1}
                aria-haspopup={item.children ? 'menu' : undefined}
                aria-expanded={item.children ? expanded : undefined}
                aria-controls={item.children && expanded ? menuId(item.id) : undefined}
                onPointerEnter={(e) => handlePointerEnter(level, item, e.currentTarget)}
                onKeyDown={(e) => handleKeyDown(e, level, item, parents)}
                onClick={() => {
                  if (!item.children) return select([...parents, item]);
                  // 親の項目を押したときは、待たずにサブメニューを開く
                  cancelTimer();
                  setOpenPath((path) => [...path.slice(0, level), item.id]);
                }}
              >
                {item.label}
                {item.children && <span aria-hidden="true">▸</span>}
              </div>
              {item.children && expanded && renderMenu(item.children, level + 1, [...parents, item])}
            </li>
          );
        })}
      </ul>
    );
  };

ul と li の組み立ては、WAI-ARIA のメニューとメニューバーのパターンに合わせました。このパターンでは、サブメニューの menu を、同じ menu の中で親の menuitem のすぐ後ろに置きます。親の menuitem には、aria-haspopup に menu を付け、サブメニューが見えているかどうかを aria-expanded の true と false で表します。li は並べるためだけの要素なので、none ロールを付けて、リストの項目としての意味をアクセシビリティツリーに出さないようにしています。

「書き出す」を開いたところで、Playwright でアクセシビリティツリーを書き出すと、次のようになりました。サブメニューの名前には、aria-labelledby で指した親の項目の文字が使われています。

text
- button "メニュー" [expanded]
- menu "メニュー":
  - menuitem "新規作成"
  - menuitem "開く"
  - menuitem "書き出す" [expanded]
  - menu "書き出す":
    - menuitem "PDF"
    - menuitem "画像"
    - menuitem "テキスト"
  - menuitem "共有"
  - menuitem "削除"

サブメニューを親の項目の右に出すのは、CSS の役目です。li を position: relative にしておき、サブメニューの ul を left: 100% で li の右端に置きます。

css
.menu li { position: relative; }
.submenu { top: -5px; left: 100%; margin: 0 0 0 2px; }

ポインターが乗ったら、少し待ってからサブメニューを切り替える

ポインターが項目に乗ったらすぐにサブメニューを切り替える作りでは、斜めに動かしたときに困ります。「書き出す」から、そのサブメニューの下のほうにある「テキスト」へ斜めに動かすと、途中で1つ下の「共有」の上を通ります。その瞬間に「共有」のサブメニューに切り替わり、目当ての「テキスト」が消えてしまいます。

上下2つの画面。どちらも「書き出す」の左寄りから「テキスト」の位置まで、ピンクの点線でポインターの通り道を描いている。上の「待ち時間 0ms」では、通り道が「共有」の上を通ったため「共有」のサブメニューが開いていて、着いた先は「メールで送る」になっている。下の「待ち時間 200ms」では「書き出す」のサブメニューが開いたままで、着いた先は「テキスト」
「書き出す」から「テキスト」へ斜めに動かした直後の画面。上が待ち時間 0ms、下が 200ms(上の帯、点線、ポインターの矢印は画像用に足したもの)

そこで、ポインターが別の項目に乗ってからサブメニューを切り替えるまでに、少し待つようにします。待っている間にポインターがサブメニューの中の項目に乗れば、その時点でタイマーを取り消すので、通り過ぎただけの「共有」は開きません。

tsx

// level 階層目の項目 item に、ポインターが乗ったとき
  const handlePointerEnter = (level: number, item: MenuItem, el: HTMLElement) => {
    el.focus();
    cancelTimer();
    // 親の項目ならそのサブメニューを開き、そうでなければ、この階層より下のサブメニューを閉じる
    const update = () =>
      setOpenPath((path) => (item.children ? [...path.slice(0, level), item.id] : path.slice(0, level)));
    if (hoverDelay === 0) update();
    else timerRef.current = window.setTimeout(update, hoverDelay);
  };

pointerenter は、ポインターが要素に入ったときに起きるイベントです。タイマーの中で実行する更新は、setOpenPath に関数を渡す形にしています。関数を渡すと、React は更新用関数として、その時点の state から次の state を作ります。待っている間に openPath が変わっても、最新の値をもとに次の配列を作れます。

検証では、「書き出す」から「テキスト」まで、16ms おきに12回に分けてポインターを動かしました(約0.2秒)。待ち時間を 0ms にした場合と 200ms にした場合の結果は次のとおりです。

text
hoverDelay=0: 通った項目 [共有 → リンクをコピー → メールで送る] / 最後の位置にある項目: メールで送る / 0.3秒後: 開いているメニュー: [メニュー, 共有] / フォーカス: メールで送る / 選んだ項目:(まだ選んでいません)
hoverDelay=200: 通った項目 [共有 → 画像 → テキスト] / 最後の位置にある項目: テキスト / 0.3秒後: 開いているメニュー: [メニュー, 書き出す] / フォーカス: テキスト / 選んだ項目:(まだ選んでいません)

待たない場合は、「共有」の上を通った時点で「共有」のサブメニューに切り替わり、ポインターが着いた位置には「メールで送る」がありました。200ms 待つ場合は、途中で「共有」と「画像」の上を通っても「書き出す」のサブメニューが開いたままで、「テキスト」に着いています。

待つ分、ふつうに親の項目へポインターを乗せたときも、サブメニューが開くのは少し後になります。検証では、乗せてから 100ms の時点ではまだ開いておらず、250ms の時点で開いていました。

text
書き出すに乗って100ms 開いているメニュー: [メニュー] / フォーカス: 書き出す / 選んだ項目:(まだ選んでいません)
書き出すに乗って250ms 開いているメニュー: [メニュー, 書き出す] / フォーカス: 書き出す / 選んだ項目:(まだ選んでいません)

待たずに開きたい人のために、親の項目を押したときはタイマーを使わずにすぐ開くようにしています(renderMenu の onClick)。乗せてすぐに「共有」を押したところ、押した直後にサブメニューが開いていました。

text
共有を押した直後 開いているメニュー: [メニュー, 共有] / フォーカス: 共有 / 選んだ項目:書き出す > 画像 > PNG

キーボードでは、→ で開いて ← と Esc で1つ上に戻る

キー操作は、先ほどのメニューのパターンにある、メニューを開いたあとのキーボード操作に合わせました。

キー 動き
↓ と ↑ 同じ階層の中で、次と前の項目に移る(端まで行ったら反対側に戻る)
→ 親の項目なら、サブメニューを開いて最初の項目に移る
Enter、Space 親の項目ならサブメニューを開く。それ以外は項目を選んでメニューを閉じる
← サブメニューを閉じて、親の項目に戻る
Esc 今いるメニューを閉じて、開いた元(親の項目かボタン)に戻る
Tab メニューをすべて閉じ、次の要素へ移る
tsx

// level 階層目の項目 item でキーが押されたとき。parents は、上の階層の親の項目
  const handleKeyDown = (e: KeyboardEvent<HTMLElement>, level: number, item: MenuItem, parents: MenuItem[]) => {
    const el = e.currentTarget;
    const siblings = [...el.closest('[role="menu"]')!.querySelectorAll<HTMLElement>(':scope > li > [role="menuitem"]')];
    const index = siblings.indexOf(el);
    const parent = parents.at(-1);
    switch (e.key) {
      case 'ArrowDown':
        siblings[(index + 1) % siblings.length].focus();
        break;
      case 'ArrowUp':
        siblings[(index - 1 + siblings.length) % siblings.length].focus();
        break;
      case 'Enter':
      case ' ':
      case 'ArrowRight':
        if (!item.children) {
          if (e.key === 'ArrowRight') return;
          select([...parents, item]);
          break;
        }
        // サブメニューを開き、その最初の項目にフォーカスを移す
        cancelTimer();
        flushSync(() => setOpenPath([...openPath.slice(0, level), item.id]));
        focusFirstIn(menuId(item.id));
        break;
      case 'ArrowLeft':
      case 'Escape':
        cancelTimer();
        if (!parent) {
          if (e.key === 'Escape') closeAll(true);
          return;
        }
        // 1つ上の階層へ戻り、開いていたサブメニューの親にフォーカスを移す
        setOpenPath(openPath.slice(0, level - 1));
        document.getElementById(itemId(parent.id))?.focus();
        break;
      case 'Tab':
        closeAll(false);
        return; // 既定の動き(次の要素へ移る)は止めない
      default:
        return;
    }
    e.preventDefault();
  };

→ でサブメニューを開いたときは、その最初の項目にフォーカスを移します。ところが、サブメニューの ul は、state を変えて React が描き直すまで DOM にありません。そこで、flushSync の中で setOpenPath を呼び、DOM を同期的に更新させてから focusFirstIn で探しています。公式リファレンスでは、flushSync は最後の手段として使うように書かれています。ここでは、→、Enter、Space でサブメニューを開くときと、ボタンから一番上のメニューを開くときだけに使っています。

Tab で preventDefault を呼ばずに return しているのは、メニューを閉じたうえで、フォーカスはブラウザの既定の動きどおり次の要素へ移したいからです。

ボタンにフォーカスがある状態から ↓ で開いた結果です。最初の ↑ で末尾の「削除」へ回り込み、「書き出す」「画像」と進んで3段目を開いたあと、← と Esc で戻っています。

text
ArrowDown  開いているメニュー: [メニュー] / フォーカス: 新規作成 / 選んだ項目:書き出す > 画像 > PNG
ArrowUp    開いているメニュー: [メニュー] / フォーカス: 削除 / 選んだ項目:書き出す > 画像 > PNG
ArrowDown  開いているメニュー: [メニュー] / フォーカス: 新規作成 / 選んだ項目:書き出す > 画像 > PNG
ArrowDown  開いているメニュー: [メニュー] / フォーカス: 開く / 選んだ項目:書き出す > 画像 > PNG
ArrowDown  開いているメニュー: [メニュー] / フォーカス: 書き出す / 選んだ項目:書き出す > 画像 > PNG
ArrowRight 開いているメニュー: [メニュー, 書き出す] / フォーカス: PDF / 選んだ項目:書き出す > 画像 > PNG
ArrowDown  開いているメニュー: [メニュー, 書き出す] / フォーカス: 画像 / 選んだ項目:書き出す > 画像 > PNG
ArrowRight 開いているメニュー: [メニュー, 書き出す, 画像] / フォーカス: PNG / 選んだ項目:書き出す > 画像 > PNG
ArrowLeft  開いているメニュー: [メニュー, 書き出す] / フォーカス: 画像 / 選んだ項目:書き出す > 画像 > PNG
Escape     開いているメニュー: [メニュー] / フォーカス: 書き出す / 選んだ項目:書き出す > 画像 > PNG
Escape     開いているメニュー: [] / フォーカス: メニュー / 選んだ項目:書き出す > 画像 > PNG

← と Esc で1段ずつ閉じていき、一番上のメニューで Esc を押すと、すべて閉じてボタンにフォーカスが戻りました。末端の項目(「リンクをコピー」)で Space を押したときも、Enter と同じように項目が選ばれてメニューが閉じ、フォーカスがボタンに戻りました。

text
Space      開いているメニュー: [] / フォーカス: メニュー / 選んだ項目:共有 > リンクをコピー

Tab を押したときは、メニューがすべて閉じて、フォーカスは次のボタンに移りました。

text
Tab        開いているメニュー: [] / フォーカス: メニューの次のボタン / 選んだ項目:共有 > リンクをコピー

ボタンには aria-haspopup と aria-expanded を付ける

メニューを開くボタンは、WAI-ARIA のメニューボタンのパターンに合わせて、aria-haspopup="menu" を付け、開いているかどうかを aria-expanded で表します。このパターンでは、Enter と Space で開いて最初の項目に移ることが求められ、↓ で開くのは任意です。Enter と Space はボタンのクリックとして onClick に届くので、onKeyDown では ↓ だけを受け取っています。

tsx

return (
    <div className="dropdown" ref={rootRef}>
      <button
        ref={buttonRef}
        id={`${baseId}-button`}
        type="button"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={open ? menuId('root') : undefined}
        onClick={() => (open ? closeAll(false) : openRoot())}
        onKeyDown={(e) => {
          if (!open && e.key === 'ArrowDown') {
            e.preventDefault();
            openRoot();
          }
        }}
      >
        {label}
      </button>
      {open && renderMenu(items, 0, [])}
    </div>
  );

メニューの外を押したときは、document の pointerdown で受け取って、すべて閉じます。押された要素が、ボタンとメニューを包む div の中にあるかどうかを contains で調べています。

tsx

// メニューの外を押したら、全部閉じる
  useEffect(() => {
    if (!open) return;
    const onPointerDown = (e: PointerEvent) => {
      if (!rootRef.current?.contains(e.target as Node)) closeAll(false);
    };
    document.addEventListener('pointerdown', onPointerDown);
    return () => document.removeEventListener('pointerdown', onPointerDown);
  }, [open]);

項目を選んだときと Esc で閉じたときは、フォーカスをボタンに戻します。外を押したときは、利用者がほかの場所を操作しようとしているので、フォーカスを戻しません。検証でも、PNG を押したあとはフォーカスがボタン(「メニュー」)にあり、外を押したあとはどこにもありませんでした。

text
PNG を押した 開いているメニュー: [] / フォーカス: メニュー / 選んだ項目:書き出す > 画像 > PNG
外を押した 開いているメニュー: [] / フォーカス: body(どこにもない) / 選んだ項目:書き出す > 画像 > PNG

この違いは、閉じる処理をまとめた closeAll の引数 returnFocus で分けています。項目を選んだときと Esc では true、外を押したときと Tab では false を渡します。

tsx

const closeAll = (returnFocus: boolean) => {
    cancelTimer();
    setOpen(false);
    setOpenPath([]);
    if (returnFocus) buttonRef.current?.focus();
  };

​Posted by 86