機能が増えるたびにメニューの項目も増え、アプリのメニューは 28 行になっていました。ノートパソコンの画面では下が切れて、スクロールしないと「お問い合わせ」まで届きません。
そこで関連する項目を、行を押すと横に開く小さなメニュー(サブメニュー)にまとめ、15 行に減らしました。この記事では、何をどうまとめたかと、サブメニューの振る舞いを React Aria Components の SubmenuTrigger にどこまで任せられたか、任せきれずに自分で作ったところ(狭い画面での開き方など)を書きます。
28 行を 15 行にまとめる
サブメニューは 6 つ作りました。中身は次のとおりです。
- AI:BPM を尋ねる・設定の編集を依頼
- BPM を測る:オーディオから・リアルタイムで・タップテンポ
- 練習ツール:チューナー・ドローン・ギャップクリック・タイマー
- 編集:元に戻す・やり直す・初期状態にリセット
- ファイル:設定ファイルのインポート・JSON を書き出し・MP3 を書き出し
- ヘルプ:キーボードショートカット・ショートカットを変更・ドキュメント・お問い合わせ
統計チャート・SYNC(ほかの端末と合わせる)・現在の設定を保存・トラックを追加・オーディオ設定・表示設定のように、よく使う項目や単独で意味が通る項目は、サブメニューに入れずにメニューの最上位(以下、トップ)に残しました。元に戻す・やり直すはキーボード(Mac は ⌘Z・⇧⌘Z、Windows は Ctrl+Z・Ctrl+Shift+Z)で使うことが多いので、メニューでは 1 段奥にあってもよいと考えました。


- サブメニューを開く行。右の矢印で、押すと横にメニューが開くことを示す
サブメニューは、見た目より振る舞いが難しい
サブメニューを開く行と、横に出すサブメニューの枠を描くだけなら、難しくありません。手間がかかるのは、マウス・キーボード・タッチ・スクリーンリーダーのそれぞれで自然に動かすところです。
たとえば、マウスを行に乗せた瞬間に開くと、マウスを上下の行へ動かしただけでサブメニューがちらつきます。開いたあと、サブメニューへ斜めにマウスを動かすと、途中で別の行の上を通るので、そのままではサブメニューが閉じてしまいます。キーボードでは、どのキーで開いて、どのキーで閉じて、閉じたあとフォーカス(キーボードで操作する対象)がどこに戻るかを決める必要があります。
アプリのメニューはもともと React Aria Components の Menu で作っていたので、サブメニューも同じライブラリの SubmenuTrigger で作りました。行の MenuItem と、サブメニューとして開く Popover を、SubmenuTrigger で包むだけです。
<SubmenuTrigger>
<MenuItem id="submenu:practiceTools">練習ツール</MenuItem>
<Popover>
<Menu onAction={onAction}>
<MenuItem id="openTuner">チューナー</MenuItem>
<MenuItem id="openDrone">ドローン</MenuItem>
</Menu>
</Popover>
</SubmenuTrigger>React Aria が引き受けてくれること
SubmenuTrigger の中身(@react-aria/menu の useSubmenuTrigger)を読むと、上で挙げた細かい振る舞いは、ひととおり作られていました。
マウスでは少し待ってから開き、斜めに動かしても閉じない
マウスを行に乗せると、200ms 後に開きます(delay の既定値)。マウスで上下の行をなぞっただけでは開きません。
開いたあとは、マウスがサブメニューに向かって動いているあいだ、親メニューの行がマウスに反応しないようにしています(useSafelyMouseToSubmenu)。向かっているかどうかは角度で決めます。直前のマウスの位置から見たサブメニューの上端と下端の向きを、それぞれ外側へ 15° 広げ、マウスの動く向きがその間に入っていれば「サブメニューへ向かっている」とみなします。途中で別の行の上を通っても、サブメニューは閉じません。マウスが 1 秒動かなければ、行はふつうに反応するようになります。

- 開いている行は、マウスが離れても背景の色を付けたままにする
- 右に場所がないので、左に開く
このアプリのメニューは画面の右端に開くので、右にはサブメニューを出す場所がありません。React Aria が位置を計算して反対側(左)に開き、画面の下端に近い行では上へずらして画面に収めます。斜めに動かしても閉じない仕組みも、左に開いたときの向きで計算しています。
キーボードでは → で開き、← で戻る
行にフォーカスがあるときに → を押すと、サブメニューが開いて先頭の項目にフォーカスが移ります。サブメニューの中で ← か Esc を押すと、サブメニューだけが閉じ、フォーカスは開いた行に戻ります。右から左に書く言語では、→ と ← が入れ替わります。

- → で開くと、先頭の項目にフォーカスが移る
マウスで開いたときはフォーカスを動かさず、キーボードやスクリーンリーダーで開いたときだけ先頭へ移します。何で開いたかは、押したときの pointerType で分けています。キーボードなら keyboard、スクリーンリーダーなら virtual です。
スクリーンリーダーには、開く行であることと開いているかを伝える
行には aria-haspopup="menu" と aria-expanded が付き、開いているあいだは aria-controls でサブメニューを指します。サブメニューは aria-labelledby で行の名前を持つので、「練習ツール」のメニューとして読み上げられます。
これらの属性は、E2E(ブラウザで画面を操作する自動テスト)でも使っています。行を押したあとに aria-expanded が true になることと、行の文字で探したメニューに項目がそろっていることを、テストで見ています。
const row = page.getByTestId("app-menu-practiceTools-submenu");
await expect(row).toHaveAttribute("aria-expanded", "true");
const submenu = page.getByRole("menu", { name: await row.innerText() });タッチでは指を離したときに開く
行を押して開くときは、マウスのクリックでもタッチでも、押し始めではなく、ボタンや指を離したときに開きます(マウスは、前の節のとおり行に乗せておくだけでも開きます)。押してすぐに開くと、開いたばかりのサブメニューの項目の上で指が離れ、その項目を押したことになるおそれがあるためです。React Aria のソースのコメントにも同じ理由が書かれています。
項目を選んだらすべてを閉じる
サブメニューの項目を選ぶと、サブメニューだけでなく親メニューも閉じます(state.closeAll)。
見た目はこれまでのメニューのまま
React Aria Components は見た目を持たないので、行やサブメニューの枠の描き方は、これまでのメニューと同じです。このアプリのデザインシステム(色・線・余白などの決まり)に従い、角丸も影もない平らな枠を 1px の線で囲みます。開く行の右には、デザインシステムにある矢印のアイコンを置いています。サブメニューと親メニューの間隔は、SubmenuTrigger の既定の 8px から、アプリのほかのポップオーバー(ボタンを押すと近くに開く小さな枠)と同じ 4px にしました。
行の状態は data 属性に出るので、CSS で見た目を変えられます。キーボードで選んでいる行には data-focused、サブメニューを開いている行には data-open が付きます。マウスがサブメニューへ移っても、どの行から開いたかがわかるよう、開いている行は背景の色を付けたままにしました。
const ROW_CLASS = cn(
"flex h-10 items-center gap-2.5 px-3.5",
FILL_CLASS.base,
// キーボードで選んでいる行と、サブメニューを開いている行は hover と同じ塗りにする
"data-focused:bg-mfm-base-hover data-open:bg-mfm-base-hover",
DISABLED_CLASS,
);自分で作ったところ
サブメニューにも onAction を渡す
親の Menu に渡した onAction は、サブメニューの Menu には引き継がれません。サブメニューは別の Menu として状態を持つためです。サブメニューの Menu にも同じ onAction を渡し、どの階層の項目を選んでも、同じ関数に項目の id が届いて、その項目の操作が動くようにしました。
狭い画面では行の下に広げる
スマートフォンでは、メニューが画面の幅のほとんどを使います。左右どちらにもサブメニューを出す場所がなく、SubmenuTrigger の既定のままでは画面の左の外へはみ出して、ほとんど見えませんでした。
最初は、画面の幅が 640px 未満のときは、押した行の真下にサブメニューを、行と同じ幅で重ねて開くようにしました。

- 押した行の真下に、行と同じ幅で重ねて開く
公開したあとでスマートフォンで使ってみると、3 つの問題がありました。
- サブメニューを開いたままメニューをスクロールすると、親メニューだけが動き、サブメニューはその場に残る
- 親の行をもう一度押しても、サブメニューが閉じない
- 親とサブメニューの幅が同じで同じ場所に重なるので、どこからがサブメニューかわからない
サブメニューが親メニューとは別の枠として上に重なっている限り、どれも残ります。開いているあいだ親のスクロールを止める手もありますが、境目のわかりにくさは変わりません。そこで狭い画面では SubmenuTrigger を使わず、親の行の下にサブメニューの項目を並べる形(アコーディオン)に変えました。

- 練習ツールの行の下に、項目を字下げして並べる。行の矢印は上向きになる
サブメニューの項目は親メニューの中に並ぶので、スクロールすると一緒に動きます。項目は字下げし、上下を 1px の線で区切りました。行の右の矢印は、閉じているときは下向き、開いているときは上向きにしています。
親の行は、押しても親メニューを閉じない MenuItem にし、押すたびに開閉します。開けるのは 1 つだけで、別の行を押すと前の行は閉じます。
<MenuItem
aria-expanded={isOpen}
shouldCloseOnSelect={false}
onAction={onToggle}
>
…
</MenuItem>
{isOpen && (
<MenuSection aria-label={submenu.label} className="border-y border-mfm-subtle">
{children}
</MenuSection>
)}shouldCloseOnSelect={false} を付けないと、行を押した時点でメニューごと閉じます。開閉のための行は、何かを動かす操作ではありません。そのため、Menu の onAction に届いた id がこの行のものなら、操作を動かす関数には渡さずに無視しています。
広い画面では、これまでどおり SubmenuTrigger で横に開きます。
E2E では、項目がどのサブメニューにあるかを表から調べる
E2E は、メニューの項目を data-testid で探して押していました。サブメニューに入れた項目は、親の行を開くまで画面にありません。
どの項目をどのサブメニューに入れるかは 1 つのファイルにまとめ、メニューの組み立てと E2E の両方がそこを読むようにしました。E2E からも読めるよう、このファイルは React に依存させていません。E2E の「メニューを開いて項目を押す」関数は、項目がサブメニューの中にあれば、親の行を先に押します。
export const APP_SUBMENUS = {
ai: ["askAiForBpm", "askAiToEditSettings"],
findBpm: ["detectBpmFromAudio", "detectBpmLive", "openTapTempo"],
practiceTools: ["openTuner", "openDrone", "openGapClick", "openTimer"],
edit: ["undoBpmSetting", "redoBpmSetting", "resetToInitialState"],
file: ["importSettingsJson", "exportSettingsJson", "exportAudio"],
help: ["openKeyboardShortcuts", "editKeyboardShortcuts", "openDocs", "contactUs"],
} as const satisfies Record<string, readonly AppMenuItemId[]>;項目を別のサブメニューへ移しても、テストは書き換えずに済みます。ドキュメントのページにもメニューがありますが、項目が 5 つしかないので、サブメニューを使わずにトップに並べました。E2E の関数は、親の行が見つからないときはトップの項目をそのまま押します。